planter_core/
task.rs

1use crate::{duration::PositiveDuration, resources::Resource};
2use anyhow::Context;
3use chrono::{DateTime, Utc};
4
5#[derive(Debug, Clone, Default, PartialEq, Eq)]
6/// A task is a unit of work that can be completed by a person or a group of people.
7/// It can be assigned resources and can have a start, finish, and duration.
8pub struct Task {
9    /// The name of the task.
10    name: String,
11    /// The description of the task.
12    description: String,
13    /// Whether the task is completed.
14    completed: bool,
15    /// The start time of the task.
16    start: Option<DateTime<Utc>>,
17    /// The finish time of the task.
18    finish: Option<DateTime<Utc>>,
19    /// The duration of the task.
20    duration: Option<PositiveDuration>,
21    /// The resources assigned to the task.
22    resources: Vec<Resource>,
23}
24
25impl Task {
26    /// Creates a new task with the given name.
27    ///
28    /// # Arguments
29    ///
30    /// * `name` - The name of the task.
31    ///
32    /// # Returns
33    ///
34    /// A new task with the given name.
35    ///
36    /// # Example
37    ///
38    /// ```
39    /// use planter_core::task::Task;
40    ///
41    /// let task = Task::new("Become world leader");
42    /// assert_eq!(task.name(), "Become world leader");
43    /// ```
44    pub fn new(name: impl Into<String>) -> Self {
45        Task {
46            name: name.into(),
47            description: String::new(),
48            completed: false,
49            start: None,
50            finish: None,
51            duration: None,
52            resources: Vec::new(),
53        }
54    }
55
56    /// Edits the start time of the task.
57    /// If a duration is already set, the finish time will be updated accordingly.
58    /// If there is a finish time set, but not a duration, the duration will be updated accordingly.
59    /// The finish time will be pushed ahead if the start time is after the finish time.
60    ///
61    /// # Arguments
62    ///
63    /// * `start` - The new start time of the task.
64    ///
65    /// # Errors
66    ///
67    /// Returns an error if the task has a finish date and the start date passed
68    /// as parameter is too far from that.
69    ///
70    /// # Example
71    ///
72    /// ```
73    /// use chrono::{Utc, Duration};
74    /// use planter_core::task::Task;
75    ///
76    /// let mut task = Task::new("Become world leader");
77    /// let start_time = Utc::now();
78    /// task.edit_start(start_time);
79    /// assert_eq!(task.start().unwrap(), start_time);
80    /// ```
81    #[allow(clippy::expect_used)]
82    pub fn edit_start(&mut self, start: DateTime<Utc>) -> anyhow::Result<()> {
83        self.start = Some(start);
84
85        if let Some(duration) = self.duration {
86            self.finish = Some(start + *duration);
87        }
88
89        if let Some(finish) = self.finish {
90            if finish < start {
91                self.finish = Some(start);
92            }
93            if self.duration().is_none() {
94                let duration = finish - start;
95                self.duration = Some(
96                    duration
97                        .try_into()
98                        .context("Start time and finish time were too far apart")?,
99                );
100            }
101        }
102        Ok(())
103    }
104
105    /// Returns the start time of the task. It's None by default.
106    ///
107    /// # Example
108    ///
109    /// ```
110    /// use chrono::{Utc};
111    /// use planter_core::task::Task;
112    ///
113    /// let mut task = Task::new("Become world leader");
114    /// assert!(task.start().is_none());
115    ///
116    /// let start_time = Utc::now();
117    /// task.edit_start(start_time);
118    /// assert_eq!(task.start().unwrap(), start_time);
119    /// ```
120    pub fn start(&self) -> Option<DateTime<Utc>> {
121        self.start
122    }
123
124    /// Edits the finish time of the task.
125    /// If there is a start time already set, duration will be updated accordingly.
126    /// Start time will be pushed back if it's after the finish time.
127    ///
128    /// # Arguments
129    ///
130    /// * `finish` - The new finish time of the task.
131    ///
132    /// # Errors
133    ///
134    /// Returns an error if the task has a start date and the finish date passed
135    /// as parameter is too far from that.
136    ///
137    ///
138    /// # Example
139    ///
140    /// ```
141    /// use chrono::{Utc};
142    /// use planter_core::task::Task;
143    ///
144    /// let mut task = Task::new("Become world leader");
145    /// assert!(task.start().is_none());
146    ///
147    /// let mut finish_time = Utc::now();
148    /// task.edit_finish(finish_time).unwrap();
149    /// assert_eq!(task.finish().unwrap(), finish_time);
150    /// ```
151    #[allow(clippy::expect_used)]
152    pub fn edit_finish(&mut self, finish: DateTime<Utc>) -> anyhow::Result<()> {
153        self.finish = Some(finish);
154
155        if let Some(start) = self.start() {
156            let start = if finish < start {
157                self.start = Some(finish);
158                finish
159            } else {
160                start
161            };
162            let duration = finish - start;
163            self.duration = Some(
164                duration
165                    .try_into()
166                    .context("Start time and finish time were too far apart")?,
167            );
168        }
169        Ok(())
170    }
171
172    /// Returns the finish time of the task. It's None by default.
173    ///
174    /// # Example
175    ///
176    /// ```
177    /// use chrono::{Utc};
178    /// use planter_core::task::Task;
179    ///
180    /// let mut task = Task::new("Become world leader");
181    /// assert!(task.finish().is_none());
182    /// let finish_time = Utc::now();
183    /// task.edit_finish(finish_time);
184    /// assert_eq!(task.finish().unwrap(), finish_time);
185    /// ```
186    pub fn finish(&self) -> Option<DateTime<Utc>> {
187        self.finish
188    }
189
190    /// Edits the duration of the task. If the task has a start time, finish time will be updated accordingly.
191    ///
192    /// # Arguments
193    ///
194    /// * `duration` - The new duration of the task.
195    ///
196    /// # Example
197    ///
198    /// ```
199    /// use chrono::{Utc, Duration};
200    /// use planter_core::{task::Task, duration::PositiveDuration};
201    ///
202    /// let mut task = Task::new("Become world leader");
203    /// task.edit_duration(Duration::minutes(30).try_into().unwrap());
204    /// assert!(task.duration().is_some());
205    /// assert_eq!(task.duration().unwrap(), Duration::minutes(30).try_into().unwrap());
206    /// ```
207    pub fn edit_duration(&mut self, duration: PositiveDuration) {
208        self.duration = Some(duration);
209
210        if let Some(start) = self.start() {
211            let finish = start + *duration;
212            self.finish = Some(finish);
213        }
214    }
215
216    /// Adds a [`Resource`] to the task.
217    ///
218    /// # Arguments
219    ///
220    /// * `resource` - The resource to add to the task.
221    ///
222    /// # Example
223    ///
224    /// ```
225    /// use planter_core::{resources::{Resource, Material, NonConsumable}, task::Task};
226    ///
227    /// let mut task = Task::new("Become world leader");
228    /// let resource = Resource::Material(Material::NonConsumable(
229    ///   NonConsumable::new("Crowbar"),
230    /// ));
231    /// task.add_resource(resource);
232    ///
233    /// assert_eq!(task.resources().len(), 1);
234    /// ```
235    pub fn add_resource(&mut self, resource: Resource) {
236        self.resources.push(resource);
237    }
238
239    /// Returns the list of [`Resource`] assigned to the task.
240    ///
241    /// # Example
242    ///
243    /// ```
244    /// use planter_core::task::Task;
245    /// use planter_core::resources::{Resource, Material, NonConsumable};
246    ///
247    /// let mut task = Task::new("Become world leader");
248    /// assert!(task.resources().is_empty());
249    /// let resource = Resource::Material(Material::NonConsumable(
250    ///   NonConsumable::new("Crowbar"),
251    /// ));
252    /// task.add_resource(resource);
253    /// assert_eq!(task.resources().len(), 1);
254    /// ```
255    pub fn resources(&self) -> &[Resource] {
256        &self.resources
257    }
258
259    /// Edits the name of the task.
260    ///
261    /// # Arguments
262    ///
263    /// * `name` - The new name of the task.
264    ///
265    /// # Example
266    ///
267    /// ```
268    /// use planter_core::task::Task;
269    ///
270    /// let mut task = Task::new("Become world leader");
271    /// task.edit_name("Become world boss");
272    /// assert_eq!(task.name(), "Become world boss");
273    /// ```
274    pub fn edit_name(&mut self, name: impl Into<String>) {
275        self.name = name.into();
276    }
277
278    /// Returns the name of the task.
279    ///
280    /// # Example
281    ///
282    /// ```
283    /// use planter_core::task::Task;
284    ///
285    /// let mut task = Task::new("Become world leader");
286    /// assert_eq!(task.name(), "Become world leader");
287    /// ```
288    pub fn name(&self) -> &str {
289        &self.name
290    }
291
292    /// Edits the description of the task.
293    ///
294    /// # Arguments
295    ///
296    /// * `description` - The new description of the task.
297    ///
298    /// # Example
299    ///
300    /// ```
301    /// use planter_core::task::Task;
302    ///
303    /// let mut task = Task::new("Become world leader");
304    /// task.edit_description("Description");
305    /// assert_eq!(task.description(), "Description");
306    /// ```
307    pub fn edit_description(&mut self, description: impl Into<String>) {
308        self.description = description.into();
309    }
310
311    /// Returns the description of the task.
312    ///
313    /// # Example
314    ///
315    /// ```
316    /// use planter_core::task::Task;
317    ///
318    /// let mut task = Task::new("Become world leader");
319    /// task.edit_description("Description");
320    /// assert_eq!(task.description(), "Description");
321    /// ```
322    pub fn description(&self) -> &str {
323        &self.description
324    }
325
326    /// Whether the task is completed. It's false by default.
327    ///
328    /// # Example
329    ///
330    /// ```
331    /// use planter_core::task::Task;
332    ///
333    /// let mut task = Task::new("Become world leader");
334    /// assert!(!task.completed());
335    /// task.toggle_completed();
336    /// assert!(task.completed());
337    /// ```
338    pub fn completed(&self) -> bool {
339        self.completed
340    }
341
342    /// Marks the task as completed.
343    ///
344    /// # Example
345    ///
346    /// ```
347    /// use planter_core::task::Task;
348    ///
349    /// let mut task = Task::new("Become world leader");
350    /// assert!(!task.completed());
351    /// task.toggle_completed();
352    /// assert!(task.completed());
353    /// task.toggle_completed();
354    /// assert!(!task.completed());
355    /// ```
356    pub fn toggle_completed(&mut self) {
357        self.completed = !self.completed;
358    }
359
360    /// Returns the duration of the task. It's None by default.
361    ///
362    /// # Example
363    ///
364    /// ```
365    /// use chrono::{Utc, Duration};
366    /// use planter_core::task::Task;
367    ///
368    /// let mut task = Task::new("Become world leader");
369    /// assert!(task.duration().is_none());
370    ///
371    /// task.edit_duration(Duration::hours(1).try_into().unwrap());
372    /// assert!(task.duration().unwrap() == Duration::hours(1).try_into().unwrap());
373    /// ```
374    pub fn duration(&self) -> Option<PositiveDuration> {
375        self.duration
376    }
377}
378
379#[cfg(test)]
380/// Utilities to test Tasks.
381pub mod test_utils {
382    use proptest::prelude::*;
383
384    use super::Task;
385
386    /// Generates an empty task with a random name.
387    pub fn task_strategy() -> impl Strategy<Value = Task> {
388        ".*".prop_map(Task::new)
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use chrono::Duration;
395    use proptest::prelude::*;
396
397    use crate::duration::MAX_DURATION;
398
399    use super::*;
400
401    proptest! {
402        #[test]
403        fn duration_is_properly_set_when_adding_start_and_finish_time(milliseconds in 0..MAX_DURATION) {
404            let start = Utc::now();
405            let finish = start + Duration::milliseconds(milliseconds);
406            let mut task = Task::new("World domination");
407
408            task.edit_start(start).unwrap();
409            task.edit_finish(finish).unwrap();
410
411            assert!(task.duration().unwrap() == Duration::milliseconds(milliseconds).try_into().unwrap());
412        }
413
414        #[test]
415        fn task_times_stay_none_when_adding_duration(milliseconds in 0..MAX_DURATION) {
416            let mut task = Task::new("World domination");
417
418            let duration = Duration::milliseconds(milliseconds).try_into().unwrap();
419            task.edit_duration(duration);
420            assert!(task.finish().is_none());
421            assert!(task.start().is_none());
422        }
423
424        #[test]
425        fn finish_time_is_properly_set_when_adding_duration(milliseconds in 0..MAX_DURATION) {
426            let start = Utc::now();
427            let mut task = Task::new("World domination");
428
429            task.edit_start(start).unwrap();
430            let duration = Duration::milliseconds(milliseconds).try_into().unwrap();
431            task.edit_duration(duration);
432            assert!(task.finish().unwrap() == start + *duration);
433        }
434
435        #[test]
436        fn finish_time_is_properly_pushed_ahead_when_adding_duration(milliseconds in 0..MAX_DURATION) {
437            let start = Utc::now();
438            let finish = start + Duration::milliseconds(milliseconds);
439            let mut task = Task::new("World domination");
440
441            task.edit_start(start).unwrap();
442            task.edit_finish(finish).unwrap();
443
444            let duration = Duration::milliseconds(milliseconds + 1).try_into().unwrap();
445            task.edit_duration(duration);
446            assert!(task.finish().unwrap() == start + *duration);
447        }
448
449
450        #[test]
451        fn start_time_is_properly_pushed_back_when_adding_earlier_finish_time(milliseconds in 0..MAX_DURATION) {
452            let start = Utc::now();
453            let finish = start - Duration::milliseconds(milliseconds);
454            let mut task = Task::new("World domination");
455
456            task.edit_start(start).unwrap();
457            task.edit_finish(finish).unwrap();
458
459            assert!(task.start().unwrap() == task.finish().unwrap());
460        }
461    }
462
463    #[test]
464    fn edit_start_returns_error_when_too_far_apart() {
465        let milliseconds = MAX_DURATION + 1;
466        let finish = Utc::now();
467        let start = finish - Duration::milliseconds(milliseconds);
468        let mut task = Task::new("World domination");
469
470        task.edit_finish(finish).unwrap();
471
472        assert!(task.edit_start(start).is_err());
473    }
474
475    #[test]
476    fn edit_finish_returns_error_when_too_far_apart() {
477        let milliseconds = MAX_DURATION + 1;
478        let start = Utc::now();
479        let finish = start + Duration::milliseconds(milliseconds);
480        let mut task = Task::new("World domination");
481
482        task.edit_start(start).unwrap();
483
484        assert!(task.edit_finish(finish).is_err());
485    }
486}