1use std::num::NonZeroU32;
2
3use crate::{
4 duration::NonNegativeDuration,
5 identifiable::{self, Identifiable},
6 money::MultiCurrencyAmount,
7 resources::Resource,
8};
9use anyhow::Context;
10use chrono::{DateTime, Utc};
11use uuid::Uuid;
12
13#[derive(Debug, Clone)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15pub struct Task {
18 id: Uuid,
20 name: String,
22 description: Option<String>,
24 completed: bool,
26 start: Option<DateTime<Utc>>,
28 finish: Option<DateTime<Utc>>,
30 duration: Option<NonNegativeDuration>,
32 assignments: Vec<Assignment>,
35}
36
37impl Identifiable for Task {
38 fn id(&self) -> Uuid {
39 self.id
40 }
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
46struct Assignment {
47 resource_id: Uuid,
48 quantity: u32,
49}
50
51impl Identifiable for Assignment {
52 fn id(&self) -> Uuid {
53 self.resource_id
54 }
55}
56
57impl Task {
58 #[must_use]
77 pub fn new(name: impl Into<String>) -> Self {
78 Task {
79 id: Uuid::new_v4(),
80 name: name.into(),
81 description: None,
82 completed: false,
83 start: None,
84 finish: None,
85 duration: None,
86 assignments: Vec::new(),
87 }
88 }
89
90 #[must_use]
101 pub const fn id(&self) -> Uuid {
102 self.id
103 }
104
105 pub fn edit_start(&mut self, start: DateTime<Utc>) -> anyhow::Result<()> {
131 self.start = Some(start);
132
133 if let Some(finish) = self.finish {
134 let finish = if finish < start { start } else { finish };
135 self.finish = Some(finish);
136 self.duration = Some(
137 (finish - start)
138 .try_into()
139 .context("Start and finish times were too far apart")?,
140 );
141 } else if let Some(duration) = self.duration {
142 self.finish = Some(start + *duration);
143 }
144 Ok(())
145 }
146
147 #[must_use]
163 pub const fn start(&self) -> Option<DateTime<Utc>> {
164 self.start
165 }
166
167 pub fn edit_finish(&mut self, finish: DateTime<Utc>) -> anyhow::Result<()> {
196 self.finish = Some(finish);
197
198 if let Some(start) = self.start {
199 let start = if finish < start {
200 self.start = Some(finish);
201 finish
202 } else {
203 start
204 };
205 let duration = finish - start;
206 self.duration = Some(
207 duration
208 .try_into()
209 .context("Start time and finish time were too far apart")?,
210 );
211 } else if let Some(duration) = self.duration {
212 self.start = Some(finish - *duration);
213 }
214 Ok(())
215 }
216
217 #[must_use]
232 pub const fn finish(&self) -> Option<DateTime<Utc>> {
233 self.finish
234 }
235
236 pub fn edit_duration(&mut self, duration: NonNegativeDuration) {
254 self.duration = Some(duration);
255
256 if let Some(start) = self.start() {
257 let finish = start + *duration;
258 self.finish = Some(finish);
259 } else if let Some(finish) = self.finish() {
260 self.start = Some(finish - *duration);
261 }
262 }
263
264 pub(crate) fn assign(&mut self, resource_id: Uuid, quantity: NonZeroU32) {
272 identifiable::upsert(
273 &mut self.assignments,
274 Assignment {
275 resource_id,
276 quantity: quantity.get(),
277 },
278 );
279 }
280
281 pub fn assignments(&self) -> impl Iterator<Item = (Uuid, u32)> + '_ {
293 self.assignments.iter().map(|a| (a.resource_id, a.quantity))
294 }
295
296 #[must_use]
308 pub fn assignment(&self, resource_id: Uuid) -> Option<u32> {
309 identifiable::find(&self.assignments, resource_id).map(|a| a.quantity)
310 }
311
312 #[must_use]
329 pub(crate) fn cost(&self, resources: &[Resource]) -> MultiCurrencyAmount {
330 let hours = self.duration_hours();
331
332 self.assignments
333 .iter()
334 .filter_map(|a| {
335 let resource = identifiable::find(resources, a.resource_id)?;
336 resource.usage_cost(hours, a.quantity)
337 })
338 .sum()
339 }
340
341 pub(crate) fn unassign(&mut self, resource_id: Uuid) -> Option<u32> {
347 identifiable::remove_by_id(&mut self.assignments, resource_id).map(|a| a.quantity)
348 }
349
350 pub fn edit_name(&mut self, name: impl Into<String>) {
366 self.name = name.into();
367 }
368
369 #[must_use]
380 pub fn name(&self) -> &str {
381 &self.name
382 }
383
384 pub fn edit_description(&mut self, description: impl Into<String>) {
400 self.description = Some(description.into());
401 }
402
403 pub fn clear_description(&mut self) {
418 self.description = None;
419 }
420
421 #[must_use]
433 pub fn description(&self) -> Option<&str> {
434 self.description.as_deref()
435 }
436
437 #[must_use]
450 pub const fn completed(&self) -> bool {
451 self.completed
452 }
453
454 pub const fn toggle_completed(&mut self) {
469 self.completed = !self.completed;
470 }
471
472 #[must_use]
487 pub const fn duration(&self) -> Option<NonNegativeDuration> {
488 self.duration
489 }
490
491 #[must_use]
507 pub fn duration_hours(&self) -> u64 {
508 self.duration
509 .map_or(0, |d| u64::try_from(d.num_hours()).unwrap_or(u64::MAX))
510 }
511}
512
513#[cfg(test)]
514pub mod test_utils {
516 use proptest::prelude::*;
517
518 use super::Task;
519
520 pub fn task_strategy() -> impl Strategy<Value = Task> {
522 ".*".prop_map(Task::new)
523 }
524}
525
526#[cfg(test)]
527mod tests {
528 use chrono::Duration;
529 use proptest::prelude::*;
530
531 use crate::money::{Currency, Money};
532 use crate::resources::Resource;
533 use crate::task::test_utils::task_strategy;
534
535 use super::*;
536
537 const MAX_TEST_MS: i64 = 1_000_000;
538
539 fn pool(resources: impl IntoIterator<Item = Resource>) -> Vec<Resource> {
541 resources.into_iter().collect()
542 }
543
544 fn nz(n: u32) -> NonZeroU32 {
546 NonZeroU32::new(n).unwrap()
547 }
548
549 proptest! {
550 #[test]
551 fn duration_is_properly_set_when_adding_start_and_finish_time(milliseconds in 0..MAX_TEST_MS) {
552 let start = Utc::now();
553 let finish = start + Duration::milliseconds(milliseconds);
554 let mut task = Task::new("World domination");
555
556 task.edit_start(start).unwrap();
557 task.edit_finish(finish).unwrap();
558
559 assert!(task.duration().unwrap() == Duration::milliseconds(milliseconds).try_into().unwrap());
560 }
561
562 #[test]
563 fn task_times_stay_none_when_adding_duration(milliseconds in 0..MAX_TEST_MS) {
564 let mut task = Task::new("World domination");
565
566 let duration = Duration::milliseconds(milliseconds).try_into().unwrap();
567 task.edit_duration(duration);
568 assert!(task.finish().is_none());
569 assert!(task.start().is_none());
570 }
571
572 #[test]
573 fn finish_time_is_properly_set_when_adding_duration(milliseconds in 0..MAX_TEST_MS) {
574 let start = Utc::now();
575 let mut task = Task::new("World domination");
576
577 task.edit_start(start).unwrap();
578 let duration = Duration::milliseconds(milliseconds).try_into().unwrap();
579 task.edit_duration(duration);
580 assert!(task.finish().unwrap() == start + *duration);
581 }
582
583 #[test]
584 fn finish_time_is_properly_pushed_ahead_when_adding_duration(milliseconds in 0..MAX_TEST_MS) {
585 let start = Utc::now();
586 let finish = start + Duration::milliseconds(milliseconds);
587 let mut task = Task::new("World domination");
588
589 task.edit_start(start).unwrap();
590 task.edit_finish(finish).unwrap();
591
592 let duration = Duration::milliseconds(milliseconds + 1).try_into().unwrap();
593 task.edit_duration(duration);
594 assert!(task.finish().unwrap() == start + *duration);
595 }
596
597
598 #[test]
599 fn start_time_is_properly_pushed_back_when_adding_earlier_finish_time(milliseconds in 0..MAX_TEST_MS) {
600 let start = Utc::now();
601 let finish = start - Duration::milliseconds(milliseconds);
602 let mut task = Task::new("World domination");
603
604 task.edit_start(start).unwrap();
605 task.edit_finish(finish).unwrap();
606
607 assert!(task.start().unwrap() == task.finish().unwrap());
608 }
609 }
610
611 #[test]
612 fn edit_start_clamps_finish_when_start_after_finish() {
613 let finish = Utc::now();
614 let start = finish + Duration::milliseconds(1);
615 let mut task = Task::new("World domination");
616
617 task.edit_finish(finish).unwrap();
618 task.edit_start(start).unwrap();
619
620 assert_eq!(task.finish(), Some(start));
621 assert_eq!(
622 task.duration(),
623 Some(Duration::milliseconds(0).try_into().unwrap())
624 );
625 }
626
627 proptest! {
628 #[test]
629 fn toggle_completed_is_self_inverse(mut task in task_strategy()) {
630 let original = task.completed();
631 task.toggle_completed();
632 assert_eq!(task.completed(), !original);
633 task.toggle_completed();
634 assert_eq!(task.completed(), original);
635 }
636
637 #[test]
638 fn assign_increments_count(mut task in task_strategy()) {
639 let count = task.assignments().count();
640 task.assign(Uuid::new_v4(), nz(1));
641 assert_eq!(task.assignments().count(), count + 1);
642 }
643
644 #[test]
645 fn edit_name_roundtrip(mut task in task_strategy(), name in ".*") {
646 task.edit_name(&name);
647 assert_eq!(task.name(), &name);
648 }
649
650 #[test]
651 fn start_without_finish_or_duration(start_millis in 0..MAX_TEST_MS) {
652 let start = Utc::now() + chrono::Duration::milliseconds(start_millis);
653 let mut task = Task::new("test");
654 task.edit_start(start).unwrap();
655 assert!(task.finish().is_none());
656 assert!(task.duration().is_none());
657 }
658
659 #[test]
660 fn edit_start_and_duration_sets_finish(milliseconds in 0..MAX_TEST_MS) {
661 let start = Utc::now();
662 let mut task = Task::new("test");
663 task.edit_start(start).unwrap();
664 let duration = chrono::Duration::milliseconds(milliseconds).try_into().unwrap();
665 task.edit_duration(duration);
666 assert_eq!(task.finish(), Some(start + *duration));
667 }
668
669 #[test]
670 fn clear_description_sets_to_none(mut task in task_strategy(), desc in ".*") {
671 task.edit_description(&desc);
672 assert_eq!(task.description(), Some(desc.as_str()));
673 task.clear_description();
674 assert!(task.description().is_none());
675 }
676
677 #[test]
678 fn unassign_works_correctly(mut task in task_strategy()) {
679 let a = Uuid::new_v4();
680 let b = Uuid::new_v4();
681 assert!(task.unassign(a).is_none());
682 task.assign(a, nz(1));
683 task.assign(b, nz(1));
684 assert_eq!(task.assignments().count(), 2);
685 assert_eq!(task.unassign(a), Some(1));
686 assert_eq!(task.assignments().count(), 1);
687 assert!(task.unassign(a).is_none());
688 }
689
690 #[test]
691 fn assigning_the_same_resource_twice_replaces_it(mut task in task_strategy()) {
692 let id = Uuid::new_v4();
693 let before = task.assignments().count();
694 task.assign(id, nz(2));
695 task.assign(id, nz(1));
696 assert_eq!(task.assignments().count(), before + 1);
697 assert_eq!(task.assignments().find(|(r, _)| *r == id), Some((id, 1)));
698 }
699
700 #[test]
701 fn edit_start_with_duration_infers_finish(milliseconds in 0..MAX_TEST_MS) {
702 let start = Utc::now();
703 let duration = chrono::Duration::milliseconds(milliseconds).try_into().unwrap();
704 let mut task = Task::new("World domination");
705 task.edit_duration(duration);
706 task.edit_start(start).unwrap();
707 assert_eq!(task.finish(), Some(start + *duration));
708 }
709
710 #[test]
711 fn edit_finish_with_duration_infers_start(milliseconds in 0..MAX_TEST_MS) {
712 let finish = Utc::now();
713 let duration = chrono::Duration::milliseconds(milliseconds).try_into().unwrap();
714 let mut task = Task::new("World domination");
715 task.edit_duration(duration);
716 task.edit_finish(finish).unwrap();
717 assert_eq!(task.start(), Some(finish - *duration));
718 }
719
720 #[test]
721 fn edit_duration_with_finish_infers_start(milliseconds in 0..MAX_TEST_MS) {
722 let finish = Utc::now();
723 let duration = chrono::Duration::milliseconds(milliseconds).try_into().unwrap();
724 let mut task = Task::new("World domination");
725 task.edit_finish(finish).unwrap();
726 task.edit_duration(duration);
727 assert_eq!(task.start(), Some(finish - *duration));
728 }
729
730 #[test]
731 fn cost_rate_only(rate in 0u64..1000, hours in 0i64..1000) {
732 let mut task = Task::new("test");
733 task.edit_duration(Duration::hours(hours).try_into().unwrap());
734
735 let resource = Resource::new("Intern".parse().unwrap()).at_hourly_rate(Money::from_minor_units(rate, Currency::EUR));
736 task.assign(resource.id(), nz(1));
737
738 let expected = Money::from_minor_units(rate * u64::try_from(hours).unwrap(), Currency::EUR);
739 assert_eq!(task.cost(&pool([resource])), expected.into());
740 }
741
742 #[test]
743 fn cost_scales_with_assignment_quantity(quantity in 1u32..1000, rate in 0u64..1000) {
744 let mut task = Task::new("test");
745 task.edit_duration(Duration::hours(1).try_into().unwrap());
746
747 let resource = Resource::new("Crowbar".parse().unwrap()).at_hourly_rate(Money::from_minor_units(rate, Currency::EUR));
748 task.assign(resource.id(), nz(quantity));
749
750 let expected = Money::from_minor_units(u64::from(quantity) * rate, Currency::EUR);
751 assert_eq!(task.cost(&pool([resource])), expected.into());
752 }
753
754 #[test]
755 fn cost_sums_every_assignment(rate in 0u64..500, hours in 0i64..500) {
756 let mut task = Task::new("test");
757 task.edit_duration(Duration::hours(hours).try_into().unwrap());
758
759 let intern = Resource::new("Intern".parse().unwrap()).at_hourly_rate(Money::from_minor_units(rate, Currency::EUR));
760 task.assign(intern.id(), nz(1));
761
762 let server = Resource::new("Server".parse().unwrap()).at_hourly_rate(Money::from_minor_units(rate, Currency::EUR));
763 task.assign(server.id(), nz(1));
764
765 let hours_u64 = u64::try_from(hours).unwrap();
766 let expected = Money::from_minor_units(2 * rate * hours_u64, Currency::EUR);
768 assert_eq!(task.cost(&pool([intern, server])), expected.into());
769 }
770 }
771
772 #[test]
773 fn assignments_are_iterated_in_insertion_order() {
774 let mut task = Task::new("test");
775 let a = Uuid::new_v4();
776 let b = Uuid::new_v4();
777 let c = Uuid::new_v4();
778 task.assign(a, nz(1));
779 task.assign(b, nz(1));
780 task.assign(c, nz(1));
781
782 let ids: Vec<_> = task.assignments().map(|(id, _)| id).collect();
783 assert_eq!(ids, vec![a, b, c]);
784 }
785
786 #[test]
787 fn cost_is_zero_with_no_assignments() {
788 let mut task = Task::new("test");
789 task.edit_duration(Duration::hours(10).try_into().unwrap());
790 assert!(task.cost(&pool([])).is_empty());
791 }
792
793 #[test]
794 fn cost_is_zero_when_the_resource_has_no_rate() {
795 let mut task = Task::new("test");
796 task.edit_duration(Duration::hours(10).try_into().unwrap());
797 let resource = Resource::new("Stimpack".parse().unwrap());
798 task.assign(resource.id(), nz(1));
799 assert!(task.cost(&pool([resource])).is_empty());
800 }
801
802 #[test]
803 fn cost_is_zero_when_duration_is_unset() {
804 let mut task = Task::new("test");
805 let resource = Resource::new("Intern".parse().unwrap())
806 .at_hourly_rate(Money::from_minor_units(50, Currency::EUR));
807 task.assign(resource.id(), nz(1));
808 assert!(task.cost(&pool([resource])).is_empty());
809 }
810
811 #[test]
812 fn cost_ignores_an_assignment_not_in_the_pool() {
813 let mut task = Task::new("test");
814 task.edit_duration(Duration::hours(10).try_into().unwrap());
815 task.assign(Uuid::new_v4(), nz(1));
816 assert!(task.cost(&pool([])).is_empty());
817 }
818
819 #[test]
820 fn cost_does_not_count_purchases() {
821 use crate::resources::Purchase;
822
823 let mut task = Task::new("test");
824 task.edit_duration(Duration::hours(10).try_into().unwrap());
825 let mut resource = Resource::new("Stimpack".parse().unwrap());
826 resource.add_purchase(
827 Purchase::builder()
828 .quantity(40)
829 .unit_price(Money::from_minor_units(500, Currency::EUR))
830 .build(),
831 );
832 task.assign(resource.id(), nz(40));
833 assert!(task.cost(&pool([resource])).is_empty());
834 }
835
836 #[test]
837 fn cost_does_not_saturate_for_a_realistic_multi_year_task() {
838 let mut task = Task::new("test");
841 task.edit_duration(Duration::hours(70_000).try_into().unwrap());
842 let resource = Resource::new("Intern".parse().unwrap())
843 .at_hourly_rate(Money::from_minor_units(u64::from(u16::MAX), Currency::EUR));
844 task.assign(resource.id(), nz(1));
845
846 assert_eq!(
847 task.cost(&pool([resource])).in_currency(Currency::EUR),
848 Some(Money::from_minor_units(
849 u64::from(u16::MAX) * 70_000,
850 Currency::EUR
851 )),
852 );
853 }
854
855 #[test]
856 fn cost_keeps_different_currencies_as_separate_entries() {
857 let mut task = Task::new("test");
858 task.edit_duration(Duration::hours(10).try_into().unwrap());
859
860 let intern = Resource::new("Intern".parse().unwrap())
861 .at_hourly_rate(Money::from_minor_units(10, Currency::EUR));
862 task.assign(intern.id(), nz(1));
863
864 let server = Resource::new("Server".parse().unwrap())
865 .at_hourly_rate(Money::from_minor_units(50, Currency::USD));
866 task.assign(server.id(), nz(1));
867
868 let cost = task.cost(&pool([intern, server]));
869 let entries: Vec<_> = cost.iter().collect();
870 assert_eq!(entries.len(), 2);
871 assert!(entries.contains(&Money::from_minor_units(100, Currency::EUR)));
872 assert!(entries.contains(&Money::from_minor_units(500, Currency::USD)));
873 }
874}
875
876#[cfg(all(test, feature = "serde"))]
877mod serde_tests {
878 use proptest::prelude::*;
879
880 use crate::task::Task;
881 use crate::task::test_utils::task_strategy;
882
883 proptest! {
884 #[test]
885 fn serde_roundtrip(task in task_strategy()) {
886 let json = serde_json::to_string(&task).unwrap();
887 let deserialized: Task = serde_json::from_str(&json).unwrap();
888 let json2 = serde_json::to_string(&deserialized).unwrap();
889 let v1: serde_json::Value = serde_json::from_str(&json).unwrap();
890 let v2: serde_json::Value = serde_json::from_str(&json2).unwrap();
891 assert_eq!(v1, v2, "serde roundtrip must produce equivalent JSON");
892 }
893 }
894}