1use std::collections::{HashMap, HashSet, VecDeque};
2use std::num::NonZeroU32;
3
4use anyhow::{Context, bail};
5use bon::Builder;
6use chrono::{DateTime, Utc};
7use uuid::Uuid;
8
9use crate::{
10 duration::NonNegativeDuration,
11 identifiable,
12 money::MultiCurrencyAmount,
13 resources::{Purchase, Resource},
14 stakeholders::Stakeholder,
15 task::Task,
16};
17
18#[derive(Debug, Default, Builder)]
19#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
20#[builder(on(String, into))]
21pub struct Project {
23 name: String,
25 description: Option<String>,
27 start_date: Option<DateTime<Utc>>,
29 end_date: Option<DateTime<Utc>>,
31 #[builder(default)]
33 tasks: Vec<Task>,
34 #[builder(default)]
36 succ: HashMap<Uuid, Vec<(Uuid, TimeRelationship)>>,
37 #[builder(default)]
39 pred: HashMap<Uuid, Vec<(Uuid, TimeRelationship)>>,
40 #[builder(default)]
42 children: HashMap<Uuid, Vec<Uuid>>,
43 #[builder(default)]
45 parent_of: HashMap<Uuid, Uuid>,
46 #[builder(default)]
49 resources: Vec<Resource>,
50 #[builder(default)]
52 stakeholders: Vec<Stakeholder>,
53}
54
55#[derive(Debug, Default, Clone, Copy)]
56#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
57pub enum TimeRelationship {
59 StartToFinish,
61 FinishToFinish,
63 #[default]
64 FinishToStart,
66 StartToStart,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
72pub enum RelDir {
74 Predecessors,
76 Successors,
78}
79
80impl Project {
81 #[must_use]
100 pub fn new(name: impl Into<String>) -> Self {
101 Self {
102 name: name.into(),
103 ..Default::default()
104 }
105 }
106
107 #[must_use]
118 pub fn name(&self) -> &str {
119 &self.name
120 }
121
122 #[must_use]
133 pub fn description(&self) -> Option<&str> {
134 self.description.as_deref()
135 }
136
137 pub fn add_task(&mut self, task: Task) -> Uuid {
160 let id = task.id();
161 identifiable::upsert(&mut self.tasks, task);
162 id
163 }
164
165 pub fn add_sibling_before(&mut self, task: Task, sibling_id: Uuid) -> anyhow::Result<Uuid> {
188 let id = task.id();
189 identifiable::insert_before(&mut self.tasks, task, sibling_id)?;
190 if let Some(&parent_id) = self.parent_of.get(&sibling_id) {
191 self.attach_child(parent_id, id);
192 }
193 Ok(id)
194 }
195
196 pub fn add_sibling_after(&mut self, task: Task, sibling_id: Uuid) -> anyhow::Result<Uuid> {
219 let id = task.id();
220 identifiable::insert_after(&mut self.tasks, task, sibling_id)?;
221 if let Some(&parent_id) = self.parent_of.get(&sibling_id) {
222 self.attach_child(parent_id, id);
223 }
224 Ok(id)
225 }
226
227 pub fn rm_task(&mut self, id: Uuid) -> anyhow::Result<Task> {
250 let task = identifiable::remove_by_id(&mut self.tasks, id)
251 .context("Tried removing a non existing task")?;
252
253 for (succ, _) in self.succ.remove(&id).into_iter().flatten() {
255 if let Some(preds) = self.pred.get_mut(&succ) {
256 preds.retain(|(p, _)| *p != id);
257 }
258 }
259 for (pred_, _) in self.pred.remove(&id).into_iter().flatten() {
260 if let Some(succs) = self.succ.get_mut(&pred_) {
261 succs.retain(|(s, _)| *s != id);
262 }
263 }
264
265 let former_parent = self.detach_child(id);
270 if let Some(children) = self.children.remove(&id) {
271 for child in children {
272 match former_parent {
273 Some(parent) => self.attach_child(parent, child),
274 None => {
275 self.parent_of.remove(&child);
276 }
277 }
278 }
279 }
280
281 Ok(task)
282 }
283
284 #[must_use]
296 pub fn task(&self, id: Uuid) -> Option<&Task> {
297 identifiable::find(&self.tasks, id)
298 }
299
300 fn task_mut(&mut self, id: Uuid) -> Option<&mut Task> {
304 identifiable::find_mut(&mut self.tasks, id)
305 }
306
307 pub fn tasks(&self) -> impl Iterator<Item = &Task> {
319 self.tasks.iter()
320 }
321
322 pub fn add_time_relationship(
348 &mut self,
349 predecessor: Uuid,
350 successor: Uuid,
351 kind: TimeRelationship,
352 ) -> anyhow::Result<()> {
353 if !identifiable::contains(&self.tasks, predecessor)
354 || !identifiable::contains(&self.tasks, successor)
355 {
356 bail!("Task not found");
357 }
358
359 if self
360 .succ
361 .get(&predecessor)
362 .map(|e| e.iter().any(|(s, _)| *s == successor))
363 .unwrap_or(false)
364 {
365 bail!("Relationship between tasks already exists");
366 }
367
368 self.validate_edge(predecessor, successor)?;
369
370 self.add_one_edge(predecessor, successor, kind);
371 Ok(())
372 }
373
374 fn validate_edge(&self, predecessor: Uuid, successor: Uuid) -> anyhow::Result<()> {
379 self.reject_ancestor_descendant_pair(predecessor, successor)?;
380 if self.would_cycle(successor, predecessor) {
381 bail!("A cycle was detected between tasks {predecessor} and {successor}");
382 }
383 Ok(())
384 }
385
386 pub fn rm_time_relationship(
410 &mut self,
411 predecessor: Uuid,
412 successor: Uuid,
413 ) -> anyhow::Result<()> {
414 let exists = self
415 .succ
416 .get(&predecessor)
417 .map(|e| e.iter().any(|(s, _)| *s == successor))
418 .unwrap_or(false);
419 if !exists {
420 bail!("Tried to remove a relationship that doesn't exist");
421 }
422 self.remove_one_edge(predecessor, successor);
423 Ok(())
424 }
425
426 pub fn successors(&self, id: Uuid) -> impl Iterator<Item = &Task> {
441 self.succ
442 .get(&id)
443 .into_iter()
444 .flatten()
445 .filter_map(move |(succ_id, _)| identifiable::find(&self.tasks, *succ_id))
446 }
447
448 pub fn successors_ids(&self, id: Uuid) -> impl Iterator<Item = Uuid> {
463 self.succ
464 .get(&id)
465 .into_iter()
466 .flatten()
467 .map(|(succ_id, _)| *succ_id)
468 }
469
470 pub fn predecessors(&self, id: Uuid) -> impl Iterator<Item = &Task> {
485 self.pred
486 .get(&id)
487 .into_iter()
488 .flatten()
489 .filter_map(move |(pred_id, _)| identifiable::find(&self.tasks, *pred_id))
490 }
491
492 pub fn predecessors_ids(&self, id: Uuid) -> impl Iterator<Item = Uuid> {
507 self.pred
508 .get(&id)
509 .into_iter()
510 .flatten()
511 .map(|(pred_id, _)| *pred_id)
512 }
513
514 pub fn update_relationships(
544 &mut self,
545 task_id: Uuid,
546 ids: &[Uuid],
547 dir: RelDir,
548 kind: TimeRelationship,
549 ) -> anyhow::Result<()> {
550 if !identifiable::contains(&self.tasks, task_id) {
551 bail!("Task {task_id} doesn't exist");
552 }
553 for &id in ids {
554 if !identifiable::contains(&self.tasks, id) {
555 bail!("Task {id} doesn't exist");
556 }
557 }
558
559 let old: HashSet<Uuid> = match dir {
560 RelDir::Predecessors => self.predecessors_ids(task_id).collect(),
561 RelDir::Successors => self.successors_ids(task_id).collect(),
562 };
563 let new: HashSet<Uuid> = ids.iter().copied().collect();
564
565 let to_add: Vec<Uuid> = ids.iter().filter(|i| !old.contains(i)).copied().collect();
566 let to_remove: Vec<Uuid> = old.iter().filter(|i| !new.contains(i)).copied().collect();
567
568 let mut added = Vec::new();
569 for &i in &to_add {
570 let (pred, succ) = match dir {
571 RelDir::Predecessors => (i, task_id),
572 RelDir::Successors => (task_id, i),
573 };
574 if let Err(e) = self.validate_edge(pred, succ) {
575 for &(p, s) in &added {
576 self.remove_one_edge(p, s);
577 }
578 return Err(e);
579 }
580 self.add_one_edge(pred, succ, kind);
581 added.push((pred, succ));
582 }
583
584 for &i in &to_remove {
585 let (pred, succ) = match dir {
586 RelDir::Predecessors => (i, task_id),
587 RelDir::Successors => (task_id, i),
588 };
589 self.remove_one_edge(pred, succ);
590 }
591
592 Ok(())
593 }
594
595 pub fn move_task_after(&mut self, id: Uuid, after_id: Uuid) -> anyhow::Result<()> {
617 identifiable::move_after(&mut self.tasks, id, after_id)
618 }
619
620 pub fn add_subtask(&mut self, parent_id: Uuid, child_id: Uuid) -> anyhow::Result<()> {
647 if !identifiable::contains(&self.tasks, parent_id)
648 || !identifiable::contains(&self.tasks, child_id)
649 {
650 bail!("Task not found");
651 }
652 if parent_id == child_id {
653 bail!("A task cannot be a subtask of itself");
654 }
655 if self.parent_of.get(&child_id) == Some(&parent_id) {
657 return Ok(());
658 }
659 let mut current = parent_id;
661 while let Some(&ancestor) = self.parent_of.get(¤t) {
662 if ancestor == child_id {
663 bail!("Cannot make a task a subtask of one of its own descendants");
664 }
665 current = ancestor;
666 }
667 self.detach_child(child_id);
669 self.attach_child(parent_id, child_id);
670 Ok(())
671 }
672
673 fn detach_child(&mut self, child_id: Uuid) -> Option<Uuid> {
677 let parent = self.parent_of.remove(&child_id)?;
678 if let Some(children) = self.children.get_mut(&parent) {
679 children.retain(|c| *c != child_id);
680 if children.is_empty() {
681 self.children.remove(&parent);
682 }
683 }
684 Some(parent)
685 }
686
687 fn attach_child(&mut self, parent_id: Uuid, child_id: Uuid) {
690 self.children.entry(parent_id).or_default().push(child_id);
691 self.parent_of.insert(child_id, parent_id);
692 }
693
694 pub fn remove_subtask(&mut self, child_id: Uuid) -> anyhow::Result<()> {
715 self.detach_child(child_id)
716 .context("Task is not a subtask")?;
717 Ok(())
718 }
719
720 pub fn task_parent(&self, child_id: Uuid) -> Option<Uuid> {
737 self.parent_of.get(&child_id).copied()
738 }
739
740 pub fn subtasks(&self, parent_id: Uuid) -> impl Iterator<Item = Uuid> + '_ {
756 self.children.get(&parent_id).into_iter().flatten().copied()
757 }
758
759 fn sync_parent_dates(&mut self, parent_id: Uuid) -> anyhow::Result<()> {
768 let earliest_start = self
769 .subtasks(parent_id)
770 .filter_map(|child_id| self.task(child_id).and_then(|t| t.start()))
771 .min();
772 let latest_finish = self
773 .subtasks(parent_id)
774 .filter_map(|child_id| self.task(child_id).and_then(|t| t.finish()))
775 .max();
776
777 if earliest_start.is_none() && latest_finish.is_none() {
778 return Ok(());
779 }
780
781 let parent = self.task_mut(parent_id).context("Parent task not found")?;
782 if let Some(start) = earliest_start
783 && parent.start().is_none_or(|ps| start < ps)
784 {
785 let _ = parent.edit_start(start);
786 }
787 if let Some(finish) = latest_finish
788 && parent.finish().is_none_or(|pf| finish > pf)
789 {
790 let _ = parent.edit_finish(finish);
791 }
792 Ok(())
793 }
794
795 fn sync_ancestors(&mut self, task_id: Uuid) {
799 let mut current = task_id;
800 while let Some(parent_id) = self.task_parent(current) {
801 let _ = self.sync_parent_dates(parent_id);
803 current = parent_id;
804 }
805 }
806
807 pub fn edit_task_start(&mut self, id: Uuid, start: DateTime<Utc>) -> anyhow::Result<()> {
834 self.task_mut(id)
835 .context("Task not found")?
836 .edit_start(start)?;
837 self.sync_ancestors(id);
838 Ok(())
839 }
840
841 pub fn edit_task_finish(&mut self, id: Uuid, finish: DateTime<Utc>) -> anyhow::Result<()> {
867 self.task_mut(id)
868 .context("Task not found")?
869 .edit_finish(finish)?;
870 self.sync_ancestors(id);
871 Ok(())
872 }
873
874 pub fn edit_task_duration(
893 &mut self,
894 id: Uuid,
895 duration: NonNegativeDuration,
896 ) -> anyhow::Result<()> {
897 self.task_mut(id)
898 .context("Task not found")?
899 .edit_duration(duration);
900 self.sync_ancestors(id);
901 Ok(())
902 }
903
904 pub fn edit_task_name(&mut self, id: Uuid, name: impl Into<String>) -> anyhow::Result<()> {
921 self.task_mut(id).context("Task not found")?.edit_name(name);
922 Ok(())
923 }
924
925 pub fn edit_task_description(
942 &mut self,
943 id: Uuid,
944 description: impl Into<String>,
945 ) -> anyhow::Result<()> {
946 self.task_mut(id)
947 .context("Task not found")?
948 .edit_description(description);
949 Ok(())
950 }
951
952 pub fn clear_task_description(&mut self, id: Uuid) -> anyhow::Result<()> {
971 self.task_mut(id)
972 .context("Task not found")?
973 .clear_description();
974 Ok(())
975 }
976
977 pub fn toggle_task_completed(&mut self, id: Uuid) -> anyhow::Result<()> {
996 self.task_mut(id)
997 .context("Task not found")?
998 .toggle_completed();
999 Ok(())
1000 }
1001
1002 #[must_use]
1015 pub const fn start_date(&self) -> Option<DateTime<Utc>> {
1016 self.start_date
1017 }
1018
1019 #[must_use]
1034 pub const fn end_date(&self) -> Option<DateTime<Utc>> {
1035 self.end_date
1036 }
1037
1038 pub const fn set_end_date(&mut self, end_date: DateTime<Utc>) {
1052 self.end_date = Some(end_date);
1053 }
1054
1055 pub fn add_resource(&mut self, resource: Resource) -> Uuid {
1076 let id = resource.id();
1077 identifiable::upsert(&mut self.resources, resource);
1078 id
1079 }
1080
1081 #[must_use]
1094 pub fn resource(&self, id: Uuid) -> Option<&Resource> {
1095 identifiable::find(&self.resources, id)
1096 }
1097
1098 pub fn rm_resource(&mut self, id: Uuid) -> anyhow::Result<(Resource, Vec<(Uuid, u32)>)> {
1121 let resource =
1122 identifiable::remove_by_id(&mut self.resources, id).context("Resource not found")?;
1123 let assignments = self
1124 .tasks
1125 .iter_mut()
1126 .filter_map(|task| task.unassign(id).map(|quantity| (task.id(), quantity)))
1127 .collect();
1128 Ok((resource, assignments))
1129 }
1130
1131 #[must_use]
1146 pub fn resource_mut(&mut self, id: Uuid) -> Option<&mut Resource> {
1147 identifiable::find_mut(&mut self.resources, id)
1148 }
1149
1150 pub fn resources(&self) -> impl Iterator<Item = &Resource> {
1162 self.resources.iter()
1163 }
1164
1165 pub fn add_purchase(&mut self, resource_id: Uuid, purchase: Purchase) -> anyhow::Result<Uuid> {
1186 Ok(self
1187 .resource_mut(resource_id)
1188 .context("Resource not found")?
1189 .add_purchase(purchase))
1190 }
1191
1192 pub fn rm_purchase(
1200 &mut self,
1201 resource_id: Uuid,
1202 purchase_id: Uuid,
1203 ) -> anyhow::Result<Purchase> {
1204 self.resource_mut(resource_id)
1205 .context("Resource not found")?
1206 .rm_purchase(purchase_id)
1207 .context("Purchase not found")
1208 }
1209
1210 pub fn purchase_mut(
1236 &mut self,
1237 resource_id: Uuid,
1238 purchase_id: Uuid,
1239 ) -> anyhow::Result<&mut Purchase> {
1240 self.resource_mut(resource_id)
1241 .context("Resource not found")?
1242 .purchase_mut(purchase_id)
1243 .context("Purchase not found")
1244 }
1245
1246 pub fn assign_resource(&mut self, task_id: Uuid, resource_id: Uuid) -> anyhow::Result<()> {
1271 self.assign_resource_units(task_id, resource_id, NonZeroU32::MIN)
1272 }
1273
1274 pub fn assign_resource_units(
1287 &mut self,
1288 task_id: Uuid,
1289 resource_id: Uuid,
1290 units: NonZeroU32,
1291 ) -> anyhow::Result<()> {
1292 if !identifiable::contains(&self.resources, resource_id) {
1293 bail!("Resource {resource_id} not found in the project's pool");
1294 }
1295
1296 let task = self.task_mut(task_id).context("Task not found")?;
1297 task.assign(resource_id, units);
1298 Ok(())
1299 }
1300
1301 pub fn unassign_resource(&mut self, task_id: Uuid, resource_id: Uuid) -> anyhow::Result<u32> {
1325 self.task_mut(task_id)
1326 .context("Task not found")?
1327 .unassign(resource_id)
1328 .context("Resource not assigned to task")
1329 }
1330
1331 #[must_use]
1348 pub fn total_cost(&self) -> MultiCurrencyAmount {
1349 let purchases: MultiCurrencyAmount =
1350 self.resources.iter().map(Resource::purchase_cost).sum();
1351 let usage: MultiCurrencyAmount = self.tasks().map(|task| task.cost(&self.resources)).sum();
1352 purchases + usage
1353 }
1354
1355 pub fn task_cost(&self, task_id: Uuid) -> anyhow::Result<MultiCurrencyAmount> {
1374 Ok(self
1375 .task(task_id)
1376 .context("Task not found")?
1377 .cost(&self.resources))
1378 }
1379
1380 pub fn resource_cost(&self, resource_id: Uuid) -> anyhow::Result<MultiCurrencyAmount> {
1416 let resource = self.resource(resource_id).context("Resource not found")?;
1417 let usage: MultiCurrencyAmount = self
1418 .tasks()
1419 .filter_map(|task| {
1420 let quantity = task.assignment(resource_id)?;
1421 resource.usage_cost(task.duration_hours(), quantity)
1422 })
1423 .sum();
1424 Ok(resource.purchase_cost() + usage)
1425 }
1426
1427 pub fn add_stakeholder(&mut self, stakeholder: Stakeholder) {
1447 self.stakeholders.push(stakeholder);
1448 }
1449
1450 #[must_use]
1466 pub fn stakeholders(&self) -> &[Stakeholder] {
1467 &self.stakeholders
1468 }
1469
1470 #[must_use]
1494 pub fn rm_stakeholder(&mut self, index: usize) -> Option<Stakeholder> {
1495 if index < self.stakeholders.len() {
1496 Some(self.stakeholders.remove(index))
1497 } else {
1498 None
1499 }
1500 }
1501
1502 fn reject_ancestor_descendant_pair(&self, a: Uuid, b: Uuid) -> anyhow::Result<()> {
1509 if self.is_ancestor(a, b) || self.is_ancestor(b, a) {
1510 bail!(
1511 "Cannot add a predecessor/successor relationship between an ancestor and a descendant"
1512 );
1513 }
1514 Ok(())
1515 }
1516
1517 fn is_ancestor(&self, ancestor_id: Uuid, descendant_id: Uuid) -> bool {
1519 let mut seen = HashSet::new();
1520 let mut current = descendant_id;
1521 while let Some(parent) = self.task_parent(current) {
1522 if !seen.insert(parent) {
1523 break;
1524 }
1525 if parent == ancestor_id {
1526 return true;
1527 }
1528 current = parent;
1529 }
1530 false
1531 }
1532
1533 fn would_cycle(&self, from: Uuid, to: Uuid) -> bool {
1535 let mut seen = HashSet::new();
1536 let mut q = VecDeque::new();
1537 q.push_back(from);
1538 while let Some(v) = q.pop_front() {
1539 if v == to {
1540 return true;
1541 }
1542 if seen.insert(v)
1543 && let Some(succs) = self.succ.get(&v)
1544 {
1545 for (s, _) in succs {
1546 q.push_back(*s);
1547 }
1548 }
1549 }
1550 false
1551 }
1552
1553 fn add_one_edge(&mut self, pred: Uuid, succ: Uuid, kind: TimeRelationship) {
1554 self.succ.entry(pred).or_default().push((succ, kind));
1555 self.pred.entry(succ).or_default().push((pred, kind));
1556 }
1557
1558 fn remove_one_edge(&mut self, pred: Uuid, succ: Uuid) {
1559 if let Some(entries) = self.succ.get_mut(&pred) {
1560 entries.retain(|(s, _)| *s != succ);
1561 if entries.is_empty() {
1562 self.succ.remove(&pred);
1563 }
1564 }
1565 if let Some(entries) = self.pred.get_mut(&succ) {
1566 entries.retain(|(p, _)| *p != pred);
1567 if entries.is_empty() {
1568 self.pred.remove(&succ);
1569 }
1570 }
1571 }
1572}
1573
1574#[cfg(test)]
1575pub mod test_utils {
1577 use proptest::{collection, prelude::*};
1578
1579 use crate::task::{Task, test_utils::task_strategy};
1580
1581 use super::{Project, RelDir, TimeRelationship};
1582
1583 const MAX_TASKS: usize = 100;
1584 const MIN_TASKS: usize = 5;
1585
1586 pub fn tasks_strategy() -> impl Strategy<Value = Vec<Task>> {
1588 collection::vec(task_strategy(), MIN_TASKS..MAX_TASKS)
1589 }
1590
1591 pub fn project_graph_strategy() -> impl Strategy<Value = Project> {
1593 (".*", tasks_strategy()).prop_map(|(n, tasks)| {
1594 let mut project = Project::builder().name(n).build();
1595 let mut ids = Vec::new();
1596 for task in tasks {
1597 ids.push(project.add_task(task));
1598 }
1599
1600 let mut previous = None;
1601 for ¤t in &ids {
1602 if let Some(prev) = previous {
1603 project
1604 .update_relationships(
1605 prev,
1606 &[current],
1607 RelDir::Successors,
1608 TimeRelationship::FinishToStart,
1609 )
1610 .unwrap();
1611 }
1612 previous = Some(current);
1613 }
1614 project
1615 })
1616 }
1617
1618 pub fn project_strategy() -> impl Strategy<Value = Project> {
1620 (".*", tasks_strategy()).prop_map(|(n, tasks)| {
1621 let mut project = Project::builder().name(n).build();
1622 for task in tasks {
1623 project.add_task(task);
1624 }
1625 project
1626 })
1627 }
1628}
1629
1630#[cfg(test)]
1631mod tests {
1632 use proptest::prelude::*;
1633 use rand::{RngExt, rng};
1634
1635 use chrono::Utc;
1636 use std::num::NonZeroU32;
1637 use uuid::Uuid;
1638
1639 use crate::{
1640 money::{Currency, Money, MultiCurrencyAmount},
1641 person::Person,
1642 project::{
1643 Project, RelDir, TimeRelationship,
1644 test_utils::{project_graph_strategy, project_strategy},
1645 },
1646 resources::{Purchase, Resource},
1647 stakeholders::Stakeholder,
1648 task::Task,
1649 };
1650
1651 fn nz(n: u32) -> std::num::NonZeroU32 {
1653 std::num::NonZeroU32::new(n).unwrap()
1654 }
1655
1656 fn task_ids(project: &Project) -> Vec<Uuid> {
1657 project.tasks().map(|t| t.id()).collect()
1658 }
1659
1660 proptest! {
1661 #[test]
1662 fn update_relationships_predecessor_rejects_circular_graphs(mut project in project_graph_strategy()) {
1663 let ids = task_ids(&project);
1664 if ids.len() < 2 { return Ok(()); }
1665 let last = *ids.last().unwrap();
1666 assert!(project.update_relationships(ids[0], &[last], RelDir::Predecessors, TimeRelationship::FinishToStart).is_err());
1667 }
1668
1669 #[test]
1670 fn update_relationships_rejects_circular_graphs(mut project in project_graph_strategy()) {
1671 let ids = task_ids(&project);
1672 if ids.len() < 2 { return Ok(()); }
1673 let last = *ids.last().unwrap();
1674 assert!(project.update_relationships(last, &[ids[0]], RelDir::Successors, TimeRelationship::FinishToStart).is_err());
1675 }
1676
1677 #[test]
1678 fn update_relationships_rejects_non_existent_ids(mut project in project_strategy()) {
1679 let ids = task_ids(&project);
1680 if ids.is_empty() { return Ok(()); }
1681 let fake = Uuid::new_v4();
1682 assert!(project.update_relationships(ids[0], &[fake], RelDir::Predecessors, TimeRelationship::FinishToStart).is_err());
1683 assert!(project.update_relationships(ids[0], &[fake], RelDir::Successors, TimeRelationship::FinishToStart).is_err());
1684 }
1685
1686 #[test]
1687 fn update_relationships_predecessor_removes_them_if_input_is_empty(mut project in project_strategy()) {
1688 let ids = task_ids(&project);
1689 if ids.len() < 2 { return Ok(()); }
1690 let mut rng = rng();
1691 let idx1 = rng.random_range(0..ids.len());
1692 let mut idx2 = idx1;
1693 while idx2 == idx1 {
1694 idx2 = rng.random_range(0..ids.len());
1695 }
1696
1697 project.update_relationships(ids[idx1], &[ids[idx2]], RelDir::Predecessors, TimeRelationship::FinishToStart).unwrap();
1698 project.update_relationships(ids[idx1], &[], RelDir::Predecessors, TimeRelationship::FinishToStart).unwrap();
1699
1700 assert_eq!(project.predecessors(ids[idx1]).count(), 0);
1701 }
1702
1703 #[test]
1704 fn update_relationships_predecessor_removes_ids_not_present_in_input(mut project in project_strategy()) {
1705 let ids = task_ids(&project);
1706 if ids.len() < 3 { return Ok(()); }
1707 let mut rng = rng();
1708 let idx1 = rng.random_range(0..ids.len());
1709 let mut idx2 = idx1;
1710 let mut idx3 = idx1;
1711 while idx2 == idx1 {
1712 idx2 = rng.random_range(0..ids.len());
1713 }
1714 while idx3 == idx1 || idx3 == idx2 {
1715 idx3 = rng.random_range(0..ids.len());
1716 }
1717
1718 project.update_relationships(ids[idx1], &[ids[idx2], ids[idx3]], RelDir::Predecessors, TimeRelationship::FinishToStart).unwrap();
1719 project.update_relationships(ids[idx1], &[ids[idx2]], RelDir::Predecessors, TimeRelationship::FinishToStart).unwrap();
1720
1721 let mut predecessors = project.predecessors(ids[idx1]);
1722 assert_eq!(predecessors.next().map(|t| t.name()), project.task(ids[idx2]).map(|t| t.name()));
1723 assert!(predecessors.next().is_none());
1724 }
1725
1726 #[test]
1727 fn update_relationships_predecessor_works(mut project in project_strategy()) {
1728 let ids = task_ids(&project);
1729 if ids.len() < 2 { return Ok(()); }
1730 let mut rng = rng();
1731 let idx1 = rng.random_range(0..ids.len());
1732 let mut idx2 = idx1;
1733 while idx2 == idx1 {
1734 idx2 = rng.random_range(0..ids.len());
1735 }
1736
1737 project.update_relationships(ids[idx1], &[ids[idx2]], RelDir::Predecessors, TimeRelationship::FinishToStart).unwrap();
1738
1739 assert_eq!(project.predecessors(ids[idx1]).count(), 1);
1740 assert_eq!(
1741 project.predecessors(ids[idx1]).next().map(|t| t.name()),
1742 project.task(ids[idx2]).map(|t| t.name())
1743 );
1744 }
1745
1746 #[test]
1747 fn update_relationships_works(mut project in project_strategy()) {
1748 let ids = task_ids(&project);
1749 if ids.len() < 2 { return Ok(()); }
1750 let mut rng = rng();
1751 let idx1 = rng.random_range(0..ids.len());
1752 let mut idx2 = idx1;
1753 while idx2 == idx1 {
1754 idx2 = rng.random_range(0..ids.len());
1755 }
1756
1757 project.update_relationships(ids[idx1], &[ids[idx2]], RelDir::Successors, TimeRelationship::FinishToStart).unwrap();
1758
1759 let mut successors = project.successors(ids[idx1]);
1760 assert_eq!(successors.next().map(|t| t.name()), project.task(ids[idx2]).map(|t| t.name()));
1761 assert!(successors.next().is_none());
1762 }
1763
1764 #[test]
1765 fn update_relationships_removes_them_if_input_is_empty(mut project in project_strategy()) {
1766 let ids = task_ids(&project);
1767 if ids.len() < 2 { return Ok(()); }
1768 let mut rng = rng();
1769 let idx1 = rng.random_range(0..ids.len());
1770 let mut idx2 = idx1;
1771 while idx2 == idx1 {
1772 idx2 = rng.random_range(0..ids.len());
1773 }
1774
1775 project.update_relationships(ids[idx1], &[ids[idx2]], RelDir::Successors, TimeRelationship::FinishToStart).unwrap();
1776 project.update_relationships(ids[idx1], &[], RelDir::Successors, TimeRelationship::FinishToStart).unwrap();
1777
1778 assert_eq!(project.successors(ids[idx1]).count(), 0);
1779 }
1780
1781 #[test]
1782 fn update_relationships_removes_ids_not_present_in_input(mut project in project_strategy()) {
1783 let ids = task_ids(&project);
1784 if ids.len() < 3 { return Ok(()); }
1785 let mut rng = rng();
1786 let idx1 = rng.random_range(0..ids.len());
1787 let mut idx2 = idx1;
1788 let mut idx3 = idx1;
1789 while idx2 == idx1 {
1790 idx2 = rng.random_range(0..ids.len());
1791 }
1792 while idx3 == idx1 || idx3 == idx2 {
1793 idx3 = rng.random_range(0..ids.len());
1794 }
1795
1796 project.update_relationships(ids[idx1], &[ids[idx2], ids[idx3]], RelDir::Successors, TimeRelationship::FinishToStart).unwrap();
1797 project.update_relationships(ids[idx1], &[ids[idx2]], RelDir::Successors, TimeRelationship::FinishToStart).unwrap();
1798
1799 let mut successors = project.successors(ids[idx1]);
1800 assert_eq!(successors.next().map(|t| t.name()), project.task(ids[idx2]).map(|t| t.name()));
1801 assert!(successors.next().is_none());
1802 }
1803 }
1804
1805 #[test]
1806 fn update_relationships_rolls_back_partial_additions_on_cycle() {
1807 let mut project = Project::new("test");
1808 let a = project.add_task(Task::new("A"));
1809 let b = project.add_task(Task::new("B"));
1810 let c = project.add_task(Task::new("C"));
1811 let d = project.add_task(Task::new("D"));
1812
1813 project
1814 .add_time_relationship(a, b, TimeRelationship::FinishToStart)
1815 .unwrap();
1816 project
1817 .add_time_relationship(b, c, TimeRelationship::FinishToStart)
1818 .unwrap();
1819 project
1820 .add_time_relationship(c, d, TimeRelationship::FinishToStart)
1821 .unwrap();
1822
1823 let old_preds: Vec<Uuid> = project.predecessors_ids(c).collect();
1824 assert_eq!(old_preds, vec![b]);
1825
1826 let result = project.update_relationships(
1827 c,
1828 &[a, d],
1829 RelDir::Predecessors,
1830 TimeRelationship::FinishToStart,
1831 );
1832 assert!(result.is_err());
1833
1834 let preds: Vec<Uuid> = project.predecessors_ids(c).collect();
1835 assert_eq!(
1836 preds,
1837 vec![b],
1838 "predecessors should be unchanged after rollback"
1839 );
1840 assert!(
1841 !project.predecessors_ids(c).any(|i| i == a),
1842 "partially-added edge a→c should have been rolled back"
1843 );
1844 }
1845
1846 #[test]
1847 fn update_relationships_handles_overlap() {
1848 let mut project = Project::new("test");
1849 let a = project.add_task(Task::new("A"));
1850 let b = project.add_task(Task::new("B"));
1851 let c = project.add_task(Task::new("C"));
1852 let d = project.add_task(Task::new("D"));
1853
1854 project
1855 .update_relationships(
1856 c,
1857 &[a, b],
1858 RelDir::Predecessors,
1859 TimeRelationship::FinishToStart,
1860 )
1861 .unwrap();
1862 let preds: Vec<Uuid> = project.predecessors_ids(c).collect();
1863 assert!(preds.contains(&a), "should contain a, got {preds:?}");
1864 assert!(preds.contains(&b), "should contain b, got {preds:?}");
1865
1866 project
1867 .update_relationships(
1868 c,
1869 &[b, d],
1870 RelDir::Predecessors,
1871 TimeRelationship::FinishToStart,
1872 )
1873 .unwrap();
1874 let preds: Vec<Uuid> = project.predecessors_ids(c).collect();
1875 assert!(preds.contains(&b));
1876 assert!(preds.contains(&d));
1877 assert!(!preds.contains(&a));
1878 }
1879
1880 #[test]
1881 fn assign_resource_rejects_unknown_task_or_resource() {
1882 let mut project = Project::new("test");
1883 let task_id = project.add_task(Task::new("Dig foundation"));
1884 let resource_id = project.add_resource(Resource::new("Crowbar".parse().unwrap()));
1885
1886 assert!(project.assign_resource(task_id, Uuid::new_v4()).is_err());
1888 assert!(
1890 project
1891 .assign_resource(Uuid::new_v4(), resource_id)
1892 .is_err()
1893 );
1894 assert!(project.assign_resource(task_id, resource_id).is_ok());
1896 assert_eq!(project.task(task_id).unwrap().assignments().count(), 1);
1897 }
1898
1899 #[test]
1900 fn task_cost_and_resource_cost_error_for_unknown_ids() {
1901 let project = Project::new("test");
1902 assert!(project.task_cost(Uuid::new_v4()).is_err());
1903 assert!(project.resource_cost(Uuid::new_v4()).is_err());
1904 }
1905
1906 #[test]
1907 fn unassign_resource_removes_the_assignment_or_errors() {
1908 let mut project = Project::new("test");
1909 let task_id = project.add_task(Task::new("Dig"));
1910 let id = project.add_resource(Resource::new("Crowbar".parse().unwrap()));
1911
1912 project.assign_resource_units(task_id, id, nz(3)).unwrap();
1913
1914 assert_eq!(project.unassign_resource(task_id, id).unwrap(), 3);
1915 assert_eq!(project.task(task_id).unwrap().assignments().count(), 0);
1916
1917 assert!(project.unassign_resource(task_id, id).is_err());
1919
1920 assert!(project.unassign_resource(Uuid::new_v4(), id).is_err());
1922 }
1923
1924 #[test]
1925 fn assigning_a_resource_a_task_already_engages_replaces_it() {
1926 let mut project = Project::new("test");
1927 let task_id = project.add_task(Task::new("Dig"));
1928 let id = project.add_resource(Resource::new("Crowbar".parse().unwrap()));
1929
1930 project.assign_resource_units(task_id, id, nz(2)).unwrap();
1931 project.assign_resource(task_id, id).unwrap();
1932
1933 let assignments: Vec<_> = project.task(task_id).unwrap().assignments().collect();
1934 assert_eq!(assignments, vec![(id, 1)]);
1935 }
1936
1937 #[test]
1938 fn rm_resource_drops_dangling_assignments() {
1939 let mut project = Project::new("test");
1940 let task_id = project.add_task(Task::new("Find a crowbar"));
1941
1942 let crowbar_id = project.add_resource(Resource::new("Crowbar".parse().unwrap()));
1943 let stimpack_id = project.add_resource(Resource::new("Stimpack".parse().unwrap()));
1944
1945 project.assign_resource(task_id, crowbar_id).unwrap();
1946 project.assign_resource(task_id, stimpack_id).unwrap();
1947 assert_eq!(project.task(task_id).unwrap().assignments().count(), 2);
1948
1949 let (_, dropped_assignments) = project.rm_resource(crowbar_id).unwrap();
1950 assert_eq!(dropped_assignments, vec![(task_id, 1)]);
1951
1952 let remaining: Vec<_> = project.task(task_id).unwrap().assignments().collect();
1953 assert_eq!(remaining, vec![(stimpack_id, 1)]);
1954 }
1955
1956 #[test]
1957 fn rm_resource_lets_a_caller_restore_its_assignments() {
1958 let mut project = Project::new("test");
1959 let task_id = project.add_task(Task::new("Dig"));
1960 let crowbar_id = project.add_resource(Resource::new("Crowbar".parse().unwrap()));
1961 project
1962 .assign_resource_units(task_id, crowbar_id, nz(3))
1963 .unwrap();
1964
1965 let (removed, dropped_assignments) = project.rm_resource(crowbar_id).unwrap();
1966 let restored_id = project.add_resource(removed);
1967
1968 for (task_id, quantity) in dropped_assignments {
1969 project
1970 .assign_resource_units(
1971 task_id,
1972 restored_id,
1973 std::num::NonZeroU32::new(quantity).unwrap(),
1974 )
1975 .unwrap();
1976 }
1977
1978 assert_eq!(
1979 project.task(task_id).unwrap().assignment(restored_id),
1980 Some(3)
1981 );
1982 }
1983
1984 #[test]
1985 fn resources_are_iterated_in_insertion_order() {
1986 let mut project = Project::new("test");
1987 let a = project.add_resource(Resource::new("Crowbar".parse().unwrap()));
1988 let b = project.add_resource(Resource::new("Stimpack".parse().unwrap()));
1989 let c = project.add_resource(Resource::new("Excavator".parse().unwrap()));
1990
1991 let ids: Vec<_> = project.resources().map(Resource::id).collect();
1992 assert_eq!(ids, vec![a, b, c]);
1993 }
1994
1995 #[test]
1996 fn add_resource_with_an_existing_id_replaces_it_in_place() {
1997 let mut project = Project::new("test");
1998 let resource = Resource::new("Crowbar".parse().unwrap());
1999 let id = project.add_resource(resource.clone());
2000
2001 let mut updated = resource;
2002 updated.set_title("Renamed crowbar".parse().unwrap());
2003 let same_id = project.add_resource(updated);
2004
2005 assert_eq!(same_id, id);
2006 assert_eq!(project.resources().count(), 1);
2007 assert_eq!(project.resource(id).unwrap().title(), "Renamed crowbar");
2008 }
2009
2010 #[test]
2011 fn add_task_with_an_existing_id_replaces_it_in_place() {
2012 let mut project = Project::new("test");
2013 let task = Task::new("Dig");
2014 let id = project.add_task(task.clone());
2015
2016 let mut updated = task;
2017 updated.edit_name("Dig deeper");
2018 let same_id = project.add_task(updated);
2019
2020 assert_eq!(same_id, id);
2021 assert_eq!(project.tasks().count(), 1);
2022 assert_eq!(project.task(id).unwrap().name(), "Dig deeper");
2023 }
2024
2025 #[test]
2026 fn a_resource_can_be_retitled_in_place_without_disturbing_assignments() {
2027 let mut project = Project::new("test");
2028 let task_id = project.add_task(Task::new("Find a crowbar"));
2029 let id = project.add_resource(Resource::new("Crobwar".parse().unwrap()));
2030 project.assign_resource(task_id, id).unwrap();
2031
2032 project
2033 .resource_mut(id)
2034 .unwrap()
2035 .set_title("Crowbar".parse().unwrap());
2036
2037 assert_eq!(project.resource(id).unwrap().title(), "Crowbar");
2038 assert_eq!(project.task(task_id).unwrap().assignments().count(), 1);
2040 }
2041
2042 fn name_strategy() -> impl Strategy<Value = String> {
2043 r"[a-zA-Z0-9]{1,30}"
2044 }
2045
2046 proptest! {
2047 #[test]
2048 fn task_add_rm_lifecycle(mut project in project_strategy()) {
2049 let initial_count = project.tasks().count();
2050 let id = project.add_task(Task::new("new task"));
2051 assert_eq!(project.tasks().count(), initial_count + 1);
2052 project.rm_task(id).unwrap();
2053 assert_eq!(project.tasks().count(), initial_count);
2054 for task in project.tasks() {
2055 assert_ne!(task.name(), "new task");
2056 }
2057 }
2058
2059 #[test]
2060 fn rm_task_cleans_subtask_relationships(name in name_strategy()) {
2061 let mut project = Project::new(name);
2062 let parent = project.add_task(Task::new("parent"));
2063 let child = project.add_task(Task::new("child"));
2064 project.add_subtask(parent, child).unwrap();
2065 assert_eq!(project.subtasks(parent).collect::<Vec<_>>(), vec![child]);
2066 project.rm_task(parent).unwrap();
2067 assert!(project.subtasks(parent).next().is_none());
2068 assert!(project.task_parent(child).is_none());
2071 }
2072
2073 #[test]
2074 fn rm_task_promotes_orphaned_children_to_grandparent(name in name_strategy()) {
2075 let mut project = Project::new(name);
2076 let grandparent = project.add_task(Task::new("grandparent"));
2077 let parent = project.add_task(Task::new("parent"));
2078 let child = project.add_task(Task::new("child"));
2079 project.add_subtask(grandparent, parent).unwrap();
2080 project.add_subtask(parent, child).unwrap();
2081
2082 project.rm_task(parent).unwrap();
2083
2084 assert_eq!(project.task_parent(child), Some(grandparent));
2085 assert!(project.subtasks(grandparent).collect::<Vec<_>>().contains(&child));
2086 }
2087
2088 #[test]
2089 fn resource_add_rm_lifecycle(name in name_strategy()) {
2090 let mut project = Project::new(name);
2091 assert_eq!(project.resources().count(), 0);
2092 let stimpack_id = project.add_resource(Resource::new("Stimpack".parse().unwrap()));
2093 assert_eq!(project.resources().count(), 1);
2094 let crowbar_id = project.add_resource(Resource::new("Crowbar".parse().unwrap()));
2095 assert_eq!(project.resources().count(), 2);
2096 let (removed, _) = project.rm_resource(stimpack_id).unwrap();
2097 assert_eq!(removed.title(), "Stimpack");
2098 assert_eq!(project.resources().count(), 1);
2099 assert_eq!(project.resource(crowbar_id).unwrap().title(), "Crowbar");
2100 }
2101
2102 #[test]
2103 fn total_cost_is_purchases_plus_task_usage(
2104 rate1 in 0u64..1000, hours1 in 0i64..1000, qty in 0u32..1000, unit_price in 0u64..1000,
2105 rate2 in 0u64..1000, hours2 in 0i64..1000,
2106 ) {
2107 let mut project = Project::new("test");
2108
2109 let intern1_id = project.add_resource(Resource::new("Intern".parse().unwrap()).at_hourly_rate(Money::from_minor_units(rate1, Currency::EUR)));
2110 let intern2_id = project.add_resource(Resource::new("Intern".parse().unwrap()).at_hourly_rate(Money::from_minor_units(rate2, Currency::EUR)));
2111
2112 let mut material = Resource::new("Stimpack".parse().unwrap());
2115 material.add_purchase(Purchase::builder().quantity(qty).unit_price(Money::from_minor_units(unit_price, Currency::EUR)).build());
2116 let material_id = project.add_resource(material);
2117
2118 let mut task1 = Task::new("task 1");
2119 task1.edit_duration(chrono::Duration::hours(hours1).try_into().unwrap());
2120 task1.assign(intern1_id, nz(1));
2121 if let Some(qty) = NonZeroU32::new(qty) {
2122 task1.assign(material_id, qty);
2123 }
2124
2125 let mut task2 = Task::new("task 2");
2126 task2.edit_duration(chrono::Duration::hours(hours2).try_into().unwrap());
2127 task2.assign(intern2_id, nz(1));
2128
2129 project.add_task(task1);
2130 project.add_task(task2);
2131
2132 let hours1_u64 = u64::try_from(hours1).unwrap();
2133 let hours2_u64 = u64::try_from(hours2).unwrap();
2134 let expected: MultiCurrencyAmount =
2135 Money::from_minor_units(rate1 * hours1_u64 + rate2 * hours2_u64 + u64::from(qty) * unit_price, Currency::EUR).into();
2136
2137 assert_eq!(project.total_cost(), expected);
2138
2139 let task_ids: Vec<_> = project.tasks().map(|t| t.id()).collect();
2141 let resource_ids: Vec<_> = project.resources().map(|r| r.id()).collect();
2142
2143 let by_task_and_purchases: MultiCurrencyAmount = task_ids
2144 .iter()
2145 .map(|&id| project.task_cost(id).unwrap())
2146 .chain(project.resources().map(Resource::purchase_cost))
2147 .sum();
2148 assert_eq!(by_task_and_purchases, expected);
2149
2150 let by_resource: MultiCurrencyAmount = resource_ids
2151 .iter()
2152 .map(|&id| project.resource_cost(id).unwrap())
2153 .sum();
2154 assert_eq!(by_resource, expected);
2155 }
2156
2157 #[test]
2158 fn total_cost_keeps_different_resource_currencies_separate(
2159 rate1 in 1u64..1000, hours1 in 1i64..1000,
2160 rate2 in 1u64..1000, hours2 in 1i64..1000,
2161 ) {
2162 let mut project = Project::new("test");
2163
2164 let intern1_id = project.add_resource(Resource::new("Intern".parse().unwrap()).at_hourly_rate(Money::from_minor_units(rate1, Currency::EUR)));
2165 let intern2_id = project.add_resource(
2166 Resource::new("Intern".parse().unwrap())
2167 .at_hourly_rate(Money::from_minor_units(rate2, Currency::USD)),
2168 );
2169
2170 let mut task1 = Task::new("task 1");
2171 task1.edit_duration(chrono::Duration::hours(hours1).try_into().unwrap());
2172 task1.assign(intern1_id, nz(1));
2173
2174 let mut task2 = Task::new("task 2");
2175 task2.edit_duration(chrono::Duration::hours(hours2).try_into().unwrap());
2176 task2.assign(intern2_id, nz(1));
2177
2178 project.add_task(task1);
2179 project.add_task(task2);
2180
2181 let total = project.total_cost();
2182 assert_eq!(total.iter().count(), 2);
2183 assert_eq!(total.in_currency(Currency::EUR), Some(Money::from_minor_units(rate1 * u64::try_from(hours1).unwrap(), Currency::EUR)));
2184 assert_eq!(
2185 total.in_currency(Currency::USD),
2186 Some(Money::from_minor_units(rate2 * u64::try_from(hours2).unwrap(), Currency::USD)),
2187 );
2188 }
2189
2190 #[test]
2191 fn stakeholder_add_increases_count(name in name_strategy(), first in "[a-zA-Z]{1,50}", last in "[a-zA-Z]{1,50}") {
2192 let mut project = Project::new(name);
2193 let p = Person::new(&first, &last).unwrap();
2194 project.add_stakeholder(Stakeholder::Individual { person: p, description: None });
2195 assert_eq!(project.stakeholders().len(), 1);
2196 }
2197
2198 #[test]
2199 fn rm_stakeholder_removes_and_returns(name in name_strategy(), first in "[a-zA-Z]{1,50}", last in "[a-zA-Z]{1,50}") {
2200 let mut project = Project::new(name);
2201 let p = Person::new(&first, &last).unwrap();
2202 project.add_stakeholder(Stakeholder::Individual { person: p.clone(), description: None });
2203 assert_eq!(project.stakeholders().len(), 1);
2204 let removed = project.rm_stakeholder(0);
2205 assert!(removed.is_some());
2206 assert_eq!(project.stakeholders().len(), 0);
2207 assert!(project.rm_stakeholder(0).is_none());
2208 }
2209
2210 #[test]
2211 fn add_time_relationship_works(mut project in project_strategy()) {
2212 let ids = task_ids(&project);
2213 if ids.len() < 2 { return Ok(()); }
2214 let mut rng = rand::rng();
2215 let p = rng.random_range(0..ids.len());
2216 let mut s = p;
2217 while s == p {
2218 s = rng.random_range(0..ids.len());
2219 }
2220
2221 project.add_time_relationship(ids[p], ids[s], TimeRelationship::FinishToStart).unwrap();
2222 let succs: Vec<_> = project.successors_ids(ids[p]).collect();
2223 assert!(succs.contains(&ids[s]), "successors({}) should contain {}", ids[p], ids[s]);
2224 let preds: Vec<_> = project.predecessors_ids(ids[s]).collect();
2225 assert!(preds.contains(&ids[p]), "predecessors({}) should contain {}", ids[s], ids[p]);
2226 }
2227
2228 #[test]
2229 fn add_time_relationship_rejects_duplicate(mut project in project_strategy()) {
2230 let ids = task_ids(&project);
2231 if ids.len() < 2 { return Ok(()); }
2232 let mut rng = rand::rng();
2233 let p = rng.random_range(0..ids.len());
2234 let mut s = p;
2235 while s == p {
2236 s = rng.random_range(0..ids.len());
2237 }
2238
2239 project.add_time_relationship(ids[p], ids[s], TimeRelationship::FinishToStart).unwrap();
2240 assert!(
2241 project.add_time_relationship(ids[p], ids[s], TimeRelationship::FinishToStart).is_err(),
2242 "duplicate edge should be rejected"
2243 );
2244 }
2245
2246 #[test]
2247 fn rm_time_relationship_works(mut project in project_strategy()) {
2248 let ids = task_ids(&project);
2249 if ids.len() < 2 { return Ok(()); }
2250 let mut rng = rand::rng();
2251 let p = rng.random_range(0..ids.len());
2252 let mut s = p;
2253 while s == p {
2254 s = rng.random_range(0..ids.len());
2255 }
2256
2257 project.add_time_relationship(ids[p], ids[s], TimeRelationship::FinishToStart).unwrap();
2258 project.rm_time_relationship(ids[p], ids[s]).unwrap();
2259 let succs: Vec<_> = project.successors_ids(ids[p]).collect();
2260 assert!(!succs.contains(&ids[s]), "successors({}) should not contain {}", ids[p], ids[s]);
2261 }
2262
2263 #[test]
2264 fn add_subtask_works(mut project in project_strategy()) {
2265 let ids = task_ids(&project);
2266 if ids.len() < 2 { return Ok(()); }
2267 let mut rng = rand::rng();
2268 let p = rng.random_range(0..ids.len());
2269 let mut c = p;
2270 while c == p {
2271 c = rng.random_range(0..ids.len());
2272 }
2273
2274 project.add_subtask(ids[p], ids[c]).unwrap();
2275 assert!(project.subtasks(ids[p]).any(|s| s == ids[c]), "subtasks({}) should contain {}", ids[p], ids[c]);
2276 }
2277
2278 #[test]
2279 fn a_resource_keeps_its_id_and_purchases_across_a_retitle(res_title in name_strategy(), qty in 1u32..1000, price in 1u64..1000) {
2280 let mut project = Project::new("project");
2281 let mut resource = Resource::new(res_title.parse().unwrap());
2282 resource.add_purchase(
2283 Purchase::builder()
2284 .quantity(qty)
2285 .unit_price(Money::from_minor_units(price, Currency::USD))
2286 .build(),
2287 );
2288 let id = project.add_resource(resource);
2289
2290 let retitled = format!("{res_title} (updated)");
2291 project.resource_mut(id).unwrap().set_title(retitled.parse().unwrap());
2292
2293 let resource = project.resource(id).unwrap();
2294 assert_eq!(resource.id(), id);
2295 assert_eq!(
2296 resource.purchases().next().unwrap().unit_price(),
2297 Money::from_minor_units(price, Currency::USD),
2298 );
2299 assert_eq!(resource.purchases().count(), 1);
2300 assert_eq!(resource.purchases().next().unwrap().quantity(), qty);
2301 assert_eq!(resource.title(), retitled);
2302 }
2303
2304 #[test]
2305 fn add_subtask_rejects_invalid_ids(name in name_strategy()) {
2306 let mut project = Project::new(name);
2307 let task = project.add_task(Task::new("only task"));
2308 let fake = Uuid::new_v4();
2309 assert!(project.subtasks(fake).next().is_none());
2310 assert!(project.add_subtask(fake, task).is_err());
2311 assert!(project.add_subtask(task, fake).is_err());
2312 }
2313
2314 #[test]
2315 fn rm_time_relationship_rejects_invalid_ids(mut project in project_strategy()) {
2316 let ids = task_ids(&project);
2317 if ids.is_empty() { return Ok(()); }
2318 let fake = Uuid::new_v4();
2319 assert!(project.rm_time_relationship(ids[0], fake).is_err());
2320 assert!(project.rm_time_relationship(fake, ids[0]).is_err());
2321 }
2322
2323 #[test]
2324 fn rm_task_rejects_invalid_id(name in name_strategy()) {
2325 let mut project = Project::new(name);
2326 let fake = Uuid::new_v4();
2327 assert!(project.rm_task(fake).is_err());
2328 }
2329
2330 #[test]
2331 fn total_cost_counts_a_personnel_resource_with_a_rate(name in name_strategy(), rate in 0u64..1000, hours in 0i64..1000) {
2332 let mut project = Project::new(name);
2333 let consultant_id = project.add_resource(
2334 Resource::new("Margherita Hack".parse().unwrap())
2335 .with_contact(Stakeholder::individual(Person::new("Margherita", "Hack").unwrap(), None))
2336 .at_hourly_rate(Money::from_minor_units(rate, Currency::EUR)),
2337 );
2338
2339 let task_id = project.add_task(Task::new("Consult"));
2340 project
2341 .edit_task_duration(task_id, chrono::Duration::hours(hours).try_into().unwrap())
2342 .unwrap();
2343 project.assign_resource(task_id, consultant_id).unwrap();
2344
2345 let expected = Money::from_minor_units(rate * u64::try_from(hours).unwrap(), Currency::EUR);
2346 assert_eq!(project.total_cost(), expected.into());
2347 }
2348 }
2349
2350 #[test]
2351 fn total_cost_is_zero_for_empty_project() {
2352 let project = Project::new("test");
2353 assert!(project.total_cost().is_empty());
2354 }
2355
2356 #[test]
2357 fn add_time_relationship_rejects_ancestor_descendant_pair() {
2358 let mut project = Project::new("test");
2359 let parent = project.add_task(Task::new("parent"));
2360 let child = project.add_task(Task::new("child"));
2361 project.add_subtask(parent, child).unwrap();
2362
2363 assert!(
2364 project
2365 .add_time_relationship(parent, child, TimeRelationship::FinishToStart)
2366 .is_err(),
2367 "should reject a time relationship between a task and its own subtask descendant"
2368 );
2369 assert!(
2370 project
2371 .add_time_relationship(child, parent, TimeRelationship::FinishToStart)
2372 .is_err(),
2373 "should reject a time relationship between a task and its own subtask ancestor"
2374 );
2375 }
2376
2377 #[test]
2378 fn add_time_relationship_rejects_invalid_ids() {
2379 let mut project = Project::new("test");
2380 let task = project.add_task(Task::new("task"));
2381 let fake = Uuid::new_v4();
2382 assert!(
2383 project
2384 .add_time_relationship(fake, task, TimeRelationship::FinishToStart)
2385 .is_err()
2386 );
2387 assert!(
2388 project
2389 .add_time_relationship(task, fake, TimeRelationship::FinishToStart)
2390 .is_err()
2391 );
2392 }
2393
2394 #[test]
2395 fn add_sibling_before_rejects_an_unknown_sibling() {
2396 let mut project = Project::new("World domination");
2397 let a = project.add_task(Task::new("Build an army"));
2398 let fake = Uuid::new_v4();
2399
2400 assert!(
2401 project
2402 .add_sibling_before(Task::new("Train troops"), fake)
2403 .is_err()
2404 );
2405 assert_eq!(project.tasks().map(|t| t.id()).collect::<Vec<_>>(), vec![a]);
2406 }
2407
2408 #[test]
2409 fn add_sibling_before_rejects_an_id_already_in_the_project() {
2410 let mut project = Project::new("World domination");
2411 let a = project.add_task(Task::new("Build an army"));
2412 let existing = project.task(a).unwrap().clone();
2413
2414 assert!(project.add_sibling_before(existing, a).is_err());
2415 assert_eq!(project.tasks().count(), 1);
2416 }
2417
2418 #[test]
2419 fn add_sibling_after_rejects_an_id_already_in_the_project() {
2420 let mut project = Project::new("World domination");
2421 let a = project.add_task(Task::new("Build an army"));
2422 let existing = project.task(a).unwrap().clone();
2423
2424 assert!(project.add_sibling_after(existing, a).is_err());
2425 assert_eq!(project.tasks().count(), 1);
2426 }
2427
2428 #[test]
2429 fn move_task_after_rejects_nonexistent_ids() {
2430 let mut project = Project::new("World domination");
2431 let a = project.add_task(Task::new("Build an army"));
2432 let fake = Uuid::new_v4();
2433
2434 assert!(project.move_task_after(fake, a).is_err());
2435 assert!(project.move_task_after(a, fake).is_err());
2436 }
2437
2438 #[test]
2439 fn move_task_after_rejects_self() {
2440 let mut project = Project::new("World domination");
2441 let a = project.add_task(Task::new("Build an army"));
2442
2443 assert!(project.move_task_after(a, a).is_err());
2444 }
2445
2446 #[test]
2447 fn remove_subtask_rejects_non_subtask() {
2448 let mut project = Project::new("World domination");
2449 let task = project.add_task(Task::new("Do something"));
2450 assert!(project.remove_subtask(task).is_err());
2451 }
2452
2453 proptest! {
2454 #[test]
2455 fn add_sibling_before_inserts_correctly(mut project in project_strategy()) {
2456 let ids = task_ids(&project);
2457 if ids.len() < 2 { return Ok(()); }
2458 let mut rng = rand::rng();
2459 let idx = rng.random_range(0..ids.len());
2460
2461 let sibling_id = ids[idx];
2462 let new_id = project.add_sibling_before(Task::new("Minion"), sibling_id).unwrap();
2463
2464 let ordered_ids: Vec<Uuid> = project.tasks().map(|t| t.id()).collect();
2465 let new_pos = ordered_ids.iter().position(|&id| id == new_id).unwrap();
2466 let sibling_pos = ordered_ids.iter().position(|&id| id == sibling_id).unwrap();
2467 assert_eq!(new_pos, sibling_pos - 1);
2468 }
2469
2470 #[test]
2471 fn add_sibling_after_inserts_correctly(mut project in project_strategy()) {
2472 let ids = task_ids(&project);
2473 if ids.len() < 2 { return Ok(()); }
2474 let mut rng = rand::rng();
2475 let idx = rng.random_range(0..ids.len());
2476
2477 let sibling_id = ids[idx];
2478 let new_id = project.add_sibling_after(Task::new("Minion"), sibling_id).unwrap();
2479
2480 let ordered_ids: Vec<Uuid> = project.tasks().map(|t| t.id()).collect();
2481 let new_pos = ordered_ids.iter().position(|&id| id == new_id).unwrap();
2482 let sibling_pos = ordered_ids.iter().position(|&id| id == sibling_id).unwrap();
2483 assert_eq!(new_pos, sibling_pos + 1);
2484 }
2485
2486 #[test]
2487 fn add_sibling_inherits_parent(mut project in project_strategy()) {
2488 let ids = task_ids(&project);
2489 if ids.len() < 3 { return Ok(()); }
2490 let mut rng = rand::rng();
2491 let parent_idx = rng.random_range(0..ids.len());
2492 let child_idx = rng.random_range(0..ids.len());
2493 if parent_idx == child_idx { return Ok(()); }
2494
2495 project.add_subtask(ids[parent_idx], ids[child_idx]).unwrap();
2496 let new_id = project
2497 .add_sibling_before(Task::new("Minion"), ids[child_idx])
2498 .unwrap();
2499
2500 assert_eq!(project.task_parent(new_id), Some(ids[parent_idx]));
2501 let children: Vec<Uuid> = project.subtasks(ids[parent_idx]).collect();
2502 assert!(children.contains(&new_id));
2503 }
2504
2505 #[test]
2506 fn move_task_after_reorders_correctly(mut project in project_strategy()) {
2507 let ids = task_ids(&project);
2508 if ids.len() < 3 { return Ok(()); }
2509 let mut rng = rand::rng();
2510 let idx = rng.random_range(0..ids.len());
2511 let mut after_idx = rng.random_range(0..ids.len());
2512 while after_idx == idx {
2513 after_idx = rng.random_range(0..ids.len());
2514 }
2515
2516 project.move_task_after(ids[idx], ids[after_idx]).unwrap();
2517 let new_ids: Vec<Uuid> = project.tasks().map(|t| t.id()).collect();
2518
2519 let task_pos = new_ids.iter().position(|&id| id == ids[idx]).unwrap();
2520 let after_pos = new_ids.iter().position(|&id| id == ids[after_idx]).unwrap();
2521 assert_eq!(task_pos, after_pos + 1);
2522 }
2523
2524 #[test]
2525 fn remove_subtask_promotes_to_top_level(mut project in project_strategy()) {
2526 let ids = task_ids(&project);
2527 if ids.len() < 2 { return Ok(()); }
2528 let mut rng = rand::rng();
2529 let parent_idx = rng.random_range(0..ids.len());
2530 let child_idx = rng.random_range(0..ids.len());
2531 if parent_idx == child_idx { return Ok(()); }
2532
2533 project.add_subtask(ids[parent_idx], ids[child_idx]).unwrap();
2534 assert!(project.task_parent(ids[child_idx]).is_some());
2535 project.remove_subtask(ids[child_idx]).unwrap();
2536
2537 assert!(project.task_parent(ids[child_idx]).is_none());
2538 assert!(project.subtasks(ids[parent_idx]).next().is_none());
2539 }
2540
2541 #[test]
2542 fn editing_a_childs_dates_expands_its_ancestors(
2543 mut project in project_strategy(),
2544 start_offset in 0..1_000_000i64,
2545 finish_offset in 0..1_000_000i64,
2546 ) {
2547 let ids = task_ids(&project);
2548 if ids.len() < 3 { return Ok(()); }
2549 let mut rng = rand::rng();
2550 let parent_idx = rng.random_range(0..ids.len());
2551 let child1_idx = rng.random_range(0..ids.len());
2552 let child2_idx = rng.random_range(0..ids.len());
2553 if child1_idx == parent_idx || child2_idx == parent_idx || child1_idx == child2_idx {
2554 return Ok(());
2555 }
2556
2557 project.add_subtask(ids[parent_idx], ids[child1_idx]).unwrap();
2558 project.add_subtask(ids[parent_idx], ids[child2_idx]).unwrap();
2559
2560 let now = Utc::now();
2561 let child1_start = now - chrono::Duration::milliseconds(start_offset);
2562 let child2_finish = now + chrono::Duration::milliseconds(finish_offset);
2563 project.edit_task_start(ids[child1_idx], child1_start).unwrap();
2564 project.edit_task_finish(ids[child2_idx], child2_finish).unwrap();
2565
2566 assert_eq!(project.task(ids[parent_idx]).unwrap().start(), Some(child1_start));
2567 assert_eq!(project.task(ids[parent_idx]).unwrap().finish(), Some(child2_finish));
2568 }
2569 }
2570
2571 #[test]
2572 fn ancestor_sync_has_no_effect_when_child_has_no_dates() {
2573 let mut project = Project::new("World domination");
2574 let army = project.add_task(Task::new("Build an army"));
2575 let supplies = project.add_task(Task::new("Gather supplies"));
2576 project.add_subtask(army, supplies).unwrap();
2577
2578 let now = Utc::now();
2579 project.edit_task_start(army, now).unwrap();
2580
2581 assert_eq!(project.task(army).unwrap().start(), Some(now));
2582 assert!(project.task(army).unwrap().finish().is_none());
2583 }
2584}
2585
2586#[cfg(all(test, feature = "serde"))]
2587mod serde_tests {
2588 use proptest::prelude::*;
2589
2590 use crate::project::Project;
2591 use crate::project::test_utils::project_strategy;
2592
2593 proptest! {
2594 #[test]
2595 fn serde_roundtrip(p in project_strategy()) {
2596 let json = serde_json::to_string(&p).unwrap();
2597 let deserialized: Project = serde_json::from_str(&json).unwrap();
2598 let json2 = serde_json::to_string(&deserialized).unwrap();
2599 let v1: serde_json::Value = serde_json::from_str(&json).unwrap();
2600 let v2: serde_json::Value = serde_json::from_str(&json2).unwrap();
2601 assert_eq!(v1, v2, "serde roundtrip must produce equivalent JSON");
2602 }
2603 }
2604}