planter_core/task.rs
1use crate::{duration::NonNegativeDuration, resources::Resource};
2use anyhow::Context;
3use chrono::{DateTime, Utc};
4use uuid::Uuid;
5
6#[derive(Debug, Clone)]
7/// A task is a unit of work that can be completed by a person or a group of people.
8/// It can be assigned resources and can have a start, finish, and duration.
9pub struct Task {
10 /// The stable identifier of the task.
11 id: Uuid,
12 /// The name of the task.
13 name: String,
14 /// The description of the task.
15 description: Option<String>,
16 /// Whether the task is completed.
17 completed: bool,
18 /// The start time of the task.
19 start: Option<DateTime<Utc>>,
20 /// The finish time of the task.
21 finish: Option<DateTime<Utc>>,
22 /// The duration of the task.
23 duration: Option<NonNegativeDuration>,
24 /// The resources assigned to the task.
25 resources: Vec<Resource>,
26}
27
28impl Task {
29 /// Creates a new task with the given name.
30 ///
31 /// # Arguments
32 ///
33 /// * `name` - The name of the task.
34 ///
35 /// # Returns
36 ///
37 /// A new task with the given name.
38 ///
39 /// # Example
40 ///
41 /// ```
42 /// use planter_core::task::Task;
43 ///
44 /// let task = Task::new("Become world leader");
45 /// assert_eq!(task.name(), "Become world leader");
46 /// ```
47 #[must_use]
48 pub fn new(name: impl Into<String>) -> Self {
49 Task {
50 id: Uuid::new_v4(),
51 name: name.into(),
52 description: None,
53 completed: false,
54 start: None,
55 finish: None,
56 duration: None,
57 resources: Vec::new(),
58 }
59 }
60
61 /// Returns the stable identifier of the task.
62 ///
63 /// # Example
64 ///
65 /// ```
66 /// use planter_core::task::Task;
67 ///
68 /// let task = Task::new("Become world leader");
69 /// let id = task.id();
70 /// ```
71 #[must_use]
72 pub const fn id(&self) -> Uuid {
73 self.id
74 }
75
76 /// Edits the start time of the task.
77 /// If a finish time is already set, the duration is recalculated to `finish - start`.
78 /// If the new start time is after the finish time, the finish time is pushed
79 /// ahead to match, resulting in a zero duration.
80 ///
81 /// # Arguments
82 ///
83 /// * `start` - The new start time of the task.
84 ///
85 /// # Errors
86 ///
87 /// Returns an error if the task has a finish date and the start date passed
88 /// as parameter is too far from that.
89 ///
90 /// # Example
91 ///
92 /// ```
93 /// use chrono::Utc;
94 /// use planter_core::task::Task;
95 ///
96 /// let mut task = Task::new("Become world leader");
97 /// let start_time = Utc::now();
98 /// task.edit_start(start_time).unwrap();
99 /// assert_eq!(task.start().unwrap(), start_time);
100 /// ```
101 pub fn edit_start(&mut self, start: DateTime<Utc>) -> anyhow::Result<()> {
102 self.start = Some(start);
103
104 if let Some(finish) = self.finish {
105 let finish = if finish < start { start } else { finish };
106 self.finish = Some(finish);
107 self.duration = Some(
108 (finish - start)
109 .try_into()
110 .context("Start and finish times were too far apart")?,
111 );
112 } else if let Some(duration) = self.duration {
113 self.finish = Some(start + *duration);
114 }
115 Ok(())
116 }
117
118 /// Returns the start time of the task. It's None by default.
119 ///
120 /// # Example
121 ///
122 /// ```
123 /// use chrono::Utc;
124 /// use planter_core::task::Task;
125 ///
126 /// let mut task = Task::new("Become world leader");
127 /// assert!(task.start().is_none());
128 ///
129 /// let start_time = Utc::now();
130 /// task.edit_start(start_time).unwrap();
131 /// assert_eq!(task.start().unwrap(), start_time);
132 /// ```
133 #[must_use]
134 pub const fn start(&self) -> Option<DateTime<Utc>> {
135 self.start
136 }
137
138 /// Edits the finish time of the task.
139 /// If a start time is already set, the duration is recalculated to `finish - start`.
140 /// If the new finish time is before the start time, the start time is pushed
141 /// back to match, resulting in a zero duration.
142 ///
143 /// # Arguments
144 ///
145 /// * `finish` - The new finish time of the task.
146 ///
147 /// # Errors
148 ///
149 /// Returns an error if the task has a start date and the finish date passed
150 /// as parameter is too far from that.
151 ///
152 ///
153 /// # Example
154 ///
155 /// ```
156 /// use chrono::Utc;
157 /// use planter_core::task::Task;
158 ///
159 /// let mut task = Task::new("Become world leader");
160 /// assert!(task.start().is_none());
161 ///
162 /// let mut finish_time = Utc::now();
163 /// task.edit_finish(finish_time).unwrap();
164 /// assert_eq!(task.finish().unwrap(), finish_time);
165 /// ```
166 pub fn edit_finish(&mut self, finish: DateTime<Utc>) -> anyhow::Result<()> {
167 self.finish = Some(finish);
168
169 if let Some(start) = self.start {
170 let start = if finish < start {
171 self.start = Some(finish);
172 finish
173 } else {
174 start
175 };
176 let duration = finish - start;
177 self.duration = Some(
178 duration
179 .try_into()
180 .context("Start time and finish time were too far apart")?,
181 );
182 } else if let Some(duration) = self.duration {
183 self.start = Some(finish - *duration);
184 }
185 Ok(())
186 }
187
188 /// Returns the finish time of the task. It's None by default.
189 ///
190 /// # Example
191 ///
192 /// ```
193 /// use chrono::Utc;
194 /// use planter_core::task::Task;
195 ///
196 /// let mut task = Task::new("Become world leader");
197 /// assert!(task.finish().is_none());
198 /// let finish_time = Utc::now();
199 /// task.edit_finish(finish_time).unwrap();
200 /// assert_eq!(task.finish().unwrap(), finish_time);
201 /// ```
202 #[must_use]
203 pub const fn finish(&self) -> Option<DateTime<Utc>> {
204 self.finish
205 }
206
207 /// Edits the duration of the task. If the task has a start time, finish time will be updated accordingly.
208 ///
209 /// # Arguments
210 ///
211 /// * `duration` - The new duration of the task.
212 ///
213 /// # Example
214 ///
215 /// ```
216 /// use chrono::{Utc, Duration};
217 /// use planter_core::{task::Task, duration::NonNegativeDuration};
218 ///
219 /// let mut task = Task::new("Become world leader");
220 /// task.edit_duration(Duration::minutes(30).try_into().unwrap());
221 /// assert!(task.duration().is_some());
222 /// assert_eq!(task.duration().unwrap(), Duration::minutes(30).try_into().unwrap());
223 /// ```
224 pub fn edit_duration(&mut self, duration: NonNegativeDuration) {
225 self.duration = Some(duration);
226
227 if let Some(start) = self.start() {
228 let finish = start + *duration;
229 self.finish = Some(finish);
230 } else if let Some(finish) = self.finish() {
231 self.start = Some(finish - *duration);
232 }
233 }
234
235 /// Adds a [`Resource`] to the task.
236 ///
237 /// # Arguments
238 ///
239 /// * `resource` - The resource to add to the task.
240 ///
241 /// # Example
242 ///
243 /// ```
244 /// use planter_core::{resources::{Resource, Material, NonConsumable}, task::Task};
245 ///
246 /// let mut task = Task::new("Become world leader");
247 /// let resource = Resource::Material(Material::NonConsumable(
248 /// NonConsumable::new("Crowbar"),
249 /// ));
250 /// task.add_resource(resource);
251 ///
252 /// assert_eq!(task.resources().len(), 1);
253 /// ```
254 pub fn add_resource(&mut self, resource: Resource) {
255 self.resources.push(resource);
256 }
257
258 /// Returns the list of [`Resource`] assigned to the task.
259 ///
260 /// # Example
261 ///
262 /// ```
263 /// use planter_core::task::Task;
264 /// use planter_core::resources::{Resource, Material, NonConsumable};
265 ///
266 /// let mut task = Task::new("Become world leader");
267 /// assert!(task.resources().is_empty());
268 /// let resource = Resource::Material(Material::NonConsumable(
269 /// NonConsumable::new("Crowbar"),
270 /// ));
271 /// task.add_resource(resource);
272 /// assert_eq!(task.resources().len(), 1);
273 /// ```
274 #[must_use]
275 pub fn resources(&self) -> &[Resource] {
276 &self.resources
277 }
278
279 /// Removes a [`Resource`] from the task by index.
280 ///
281 /// # Arguments
282 ///
283 /// * `index` - The index of the resource to remove.
284 ///
285 /// # Returns
286 ///
287 /// The removed resource, or `None` if the index is out of bounds.
288 ///
289 /// # Example
290 ///
291 /// ```
292 /// use planter_core::{resources::{Resource, Material, NonConsumable}, task::Task};
293 ///
294 /// let mut task = Task::new("Become world leader");
295 /// let resource = Resource::Material(Material::NonConsumable(
296 /// NonConsumable::new("Crowbar"),
297 /// ));
298 /// task.add_resource(resource);
299 /// assert_eq!(task.resources().len(), 1);
300 ///
301 /// let removed = task.rm_resource(0);
302 /// assert!(removed.is_some());
303 /// assert_eq!(task.resources().len(), 0);
304 /// ```
305 #[must_use]
306 pub fn rm_resource(&mut self, index: usize) -> Option<Resource> {
307 if index < self.resources.len() {
308 Some(self.resources.remove(index))
309 } else {
310 None
311 }
312 }
313
314 /// Edits the name of the task.
315 ///
316 /// # Arguments
317 ///
318 /// * `name` - The new name of the task.
319 ///
320 /// # Example
321 ///
322 /// ```
323 /// use planter_core::task::Task;
324 ///
325 /// let mut task = Task::new("Become world leader");
326 /// task.edit_name("Become world boss");
327 /// assert_eq!(task.name(), "Become world boss");
328 /// ```
329 pub fn edit_name(&mut self, name: impl Into<String>) {
330 self.name = name.into();
331 }
332
333 /// Returns the name of the task.
334 ///
335 /// # Example
336 ///
337 /// ```
338 /// use planter_core::task::Task;
339 ///
340 /// let mut task = Task::new("Become world leader");
341 /// assert_eq!(task.name(), "Become world leader");
342 /// ```
343 #[must_use]
344 pub fn name(&self) -> &str {
345 &self.name
346 }
347
348 /// Edits the description of the task.
349 ///
350 /// # Arguments
351 ///
352 /// * `description` - The new description of the task.
353 ///
354 /// # Example
355 ///
356 /// ```
357 /// use planter_core::task::Task;
358 ///
359 /// let mut task = Task::new("Become world leader");
360 /// task.edit_description("Description");
361 /// assert_eq!(task.description(), Some("Description"));
362 /// ```
363 pub fn edit_description(&mut self, description: impl Into<String>) {
364 self.description = Some(description.into());
365 }
366
367 /// Clears the description of the task, setting it to `None`.
368 ///
369 /// # Example
370 ///
371 /// ```
372 /// use planter_core::task::Task;
373 ///
374 /// let mut task = Task::new("Become world leader");
375 /// task.edit_description("Description");
376 /// assert_eq!(task.description(), Some("Description"));
377 ///
378 /// task.clear_description();
379 /// assert!(task.description().is_none());
380 /// ```
381 pub fn clear_description(&mut self) {
382 self.description = None;
383 }
384
385 /// Returns the description of the task.
386 ///
387 /// # Example
388 ///
389 /// ```
390 /// use planter_core::task::Task;
391 ///
392 /// let mut task = Task::new("Become world leader");
393 /// task.edit_description("Description");
394 /// assert_eq!(task.description(), Some("Description"));
395 /// ```
396 #[must_use]
397 pub fn description(&self) -> Option<&str> {
398 self.description.as_deref()
399 }
400
401 /// Whether the task is completed. It's false by default.
402 ///
403 /// # Example
404 ///
405 /// ```
406 /// use planter_core::task::Task;
407 ///
408 /// let mut task = Task::new("Become world leader");
409 /// assert!(!task.completed());
410 /// task.toggle_completed();
411 /// assert!(task.completed());
412 /// ```
413 #[must_use]
414 pub const fn completed(&self) -> bool {
415 self.completed
416 }
417
418 /// Marks the task as completed.
419 ///
420 /// # Example
421 ///
422 /// ```
423 /// use planter_core::task::Task;
424 ///
425 /// let mut task = Task::new("Become world leader");
426 /// assert!(!task.completed());
427 /// task.toggle_completed();
428 /// assert!(task.completed());
429 /// task.toggle_completed();
430 /// assert!(!task.completed());
431 /// ```
432 pub const fn toggle_completed(&mut self) {
433 self.completed = !self.completed;
434 }
435
436 /// Returns the duration of the task. It's None by default.
437 ///
438 /// # Example
439 ///
440 /// ```
441 /// use chrono::{Utc, Duration};
442 /// use planter_core::task::Task;
443 ///
444 /// let mut task = Task::new("Become world leader");
445 /// assert!(task.duration().is_none());
446 ///
447 /// task.edit_duration(Duration::hours(1).try_into().unwrap());
448 /// assert!(task.duration().unwrap() == Duration::hours(1).try_into().unwrap());
449 /// ```
450 #[must_use]
451 pub const fn duration(&self) -> Option<NonNegativeDuration> {
452 self.duration
453 }
454}
455
456#[cfg(test)]
457/// Utilities to test Tasks.
458pub mod test_utils {
459 use proptest::prelude::*;
460
461 use super::Task;
462
463 /// Generates an empty task with a random name.
464 pub fn task_strategy() -> impl Strategy<Value = Task> {
465 ".*".prop_map(Task::new)
466 }
467}
468
469#[cfg(test)]
470mod tests {
471 use chrono::Duration;
472 use proptest::prelude::*;
473
474 use crate::resources::{Material, Resource};
475 use crate::task::test_utils::task_strategy;
476
477 use super::*;
478
479 const MAX_TEST_MS: i64 = 1_000_000;
480
481 proptest! {
482 #[test]
483 fn duration_is_properly_set_when_adding_start_and_finish_time(milliseconds in 0..MAX_TEST_MS) {
484 let start = Utc::now();
485 let finish = start + Duration::milliseconds(milliseconds);
486 let mut task = Task::new("World domination");
487
488 task.edit_start(start).unwrap();
489 task.edit_finish(finish).unwrap();
490
491 assert!(task.duration().unwrap() == Duration::milliseconds(milliseconds).try_into().unwrap());
492 }
493
494 #[test]
495 fn task_times_stay_none_when_adding_duration(milliseconds in 0..MAX_TEST_MS) {
496 let mut task = Task::new("World domination");
497
498 let duration = Duration::milliseconds(milliseconds).try_into().unwrap();
499 task.edit_duration(duration);
500 assert!(task.finish().is_none());
501 assert!(task.start().is_none());
502 }
503
504 #[test]
505 fn finish_time_is_properly_set_when_adding_duration(milliseconds in 0..MAX_TEST_MS) {
506 let start = Utc::now();
507 let mut task = Task::new("World domination");
508
509 task.edit_start(start).unwrap();
510 let duration = Duration::milliseconds(milliseconds).try_into().unwrap();
511 task.edit_duration(duration);
512 assert!(task.finish().unwrap() == start + *duration);
513 }
514
515 #[test]
516 fn finish_time_is_properly_pushed_ahead_when_adding_duration(milliseconds in 0..MAX_TEST_MS) {
517 let start = Utc::now();
518 let finish = start + Duration::milliseconds(milliseconds);
519 let mut task = Task::new("World domination");
520
521 task.edit_start(start).unwrap();
522 task.edit_finish(finish).unwrap();
523
524 let duration = Duration::milliseconds(milliseconds + 1).try_into().unwrap();
525 task.edit_duration(duration);
526 assert!(task.finish().unwrap() == start + *duration);
527 }
528
529
530 #[test]
531 fn start_time_is_properly_pushed_back_when_adding_earlier_finish_time(milliseconds in 0..MAX_TEST_MS) {
532 let start = Utc::now();
533 let finish = start - Duration::milliseconds(milliseconds);
534 let mut task = Task::new("World domination");
535
536 task.edit_start(start).unwrap();
537 task.edit_finish(finish).unwrap();
538
539 assert!(task.start().unwrap() == task.finish().unwrap());
540 }
541 }
542
543 #[test]
544 fn edit_start_clamps_finish_when_start_after_finish() {
545 let finish = Utc::now();
546 let start = finish + Duration::milliseconds(1);
547 let mut task = Task::new("World domination");
548
549 task.edit_finish(finish).unwrap();
550 task.edit_start(start).unwrap();
551
552 assert_eq!(task.finish(), Some(start));
553 assert_eq!(
554 task.duration(),
555 Some(Duration::milliseconds(0).try_into().unwrap())
556 );
557 }
558
559 proptest! {
560 #[test]
561 fn toggle_completed_is_self_inverse(mut task in task_strategy()) {
562 let original = task.completed();
563 task.toggle_completed();
564 assert_eq!(task.completed(), !original);
565 task.toggle_completed();
566 assert_eq!(task.completed(), original);
567 }
568
569 #[test]
570 fn add_resource_increments_count(mut task in task_strategy()) {
571 let count = task.resources().len();
572 task.add_resource(Resource::Material(Material::new("test")));
573 assert_eq!(task.resources().len(), count + 1);
574 }
575
576 #[test]
577 fn edit_name_roundtrip(mut task in task_strategy(), name in ".*") {
578 task.edit_name(&name);
579 assert_eq!(task.name(), &name);
580 }
581
582 #[test]
583 fn start_without_finish_or_duration(start_millis in 0..MAX_TEST_MS) {
584 let start = Utc::now() + chrono::Duration::milliseconds(start_millis);
585 let mut task = Task::new("test");
586 task.edit_start(start).unwrap();
587 assert!(task.finish().is_none());
588 assert!(task.duration().is_none());
589 }
590
591 #[test]
592 fn edit_start_and_duration_sets_finish(milliseconds in 0..MAX_TEST_MS) {
593 let start = Utc::now();
594 let mut task = Task::new("test");
595 task.edit_start(start).unwrap();
596 let duration = chrono::Duration::milliseconds(milliseconds).try_into().unwrap();
597 task.edit_duration(duration);
598 assert_eq!(task.finish(), Some(start + *duration));
599 }
600
601 #[test]
602 fn clear_description_sets_to_none(mut task in task_strategy(), desc in ".*") {
603 task.edit_description(&desc);
604 assert_eq!(task.description(), Some(desc.as_str()));
605 task.clear_description();
606 assert!(task.description().is_none());
607 }
608
609 #[test]
610 fn rm_resource_works_correctly(mut task in task_strategy()) {
611 assert!(task.rm_resource(0).is_none());
612 task.add_resource(Resource::Material(Material::new("a")));
613 task.add_resource(Resource::Material(Material::new("b")));
614 assert_eq!(task.resources().len(), 2);
615 let removed = task.rm_resource(0);
616 assert!(removed.is_some());
617 assert_eq!(task.resources().len(), 1);
618 assert!(task.rm_resource(1).is_none());
619 }
620
621 #[test]
622 fn edit_start_with_duration_infers_finish(milliseconds in 0..MAX_TEST_MS) {
623 let start = Utc::now();
624 let duration = chrono::Duration::milliseconds(milliseconds).try_into().unwrap();
625 let mut task = Task::new("World domination");
626 task.edit_duration(duration);
627 task.edit_start(start).unwrap();
628 assert_eq!(task.finish(), Some(start + *duration));
629 }
630
631 #[test]
632 fn edit_finish_with_duration_infers_start(milliseconds in 0..MAX_TEST_MS) {
633 let finish = Utc::now();
634 let duration = chrono::Duration::milliseconds(milliseconds).try_into().unwrap();
635 let mut task = Task::new("World domination");
636 task.edit_duration(duration);
637 task.edit_finish(finish).unwrap();
638 assert_eq!(task.start(), Some(finish - *duration));
639 }
640
641 #[test]
642 fn edit_duration_with_finish_infers_start(milliseconds in 0..MAX_TEST_MS) {
643 let finish = Utc::now();
644 let duration = chrono::Duration::milliseconds(milliseconds).try_into().unwrap();
645 let mut task = Task::new("World domination");
646 task.edit_finish(finish).unwrap();
647 task.edit_duration(duration);
648 assert_eq!(task.start(), Some(finish - *duration));
649 }
650 }
651}