Skip to main content

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}