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