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/// 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}