Skip to main content

planter_core/
project.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2
3use anyhow::{Context, bail};
4use bon::Builder;
5use chrono::{DateTime, Utc};
6use thiserror::Error;
7use uuid::Uuid;
8
9use crate::{
10    resources::{Material, Resource},
11    stakeholders::Stakeholder,
12    task::Task,
13};
14
15#[derive(Debug, Default, Builder)]
16#[builder(on(String, into))]
17/// Represents a project with a name and a list of resources.
18pub struct Project {
19    /// The name of the project.
20    name: String,
21    /// The description of the project.
22    description: Option<String>,
23    /// The start date of the project.
24    start_date: Option<DateTime<Utc>>,
25    /// The tasks associated with the project.
26    #[builder(default)]
27    tasks: HashMap<Uuid, Task>,
28    /// Insertion order of tasks.
29    #[builder(default)]
30    task_order: Vec<Uuid>,
31    /// Successor relationships for the time-relationship DAG.
32    #[builder(default)]
33    succ: HashMap<Uuid, Vec<(Uuid, TimeRelationship)>>,
34    /// Predecessor relationships for the time-relationship DAG.
35    #[builder(default)]
36    pred: HashMap<Uuid, Vec<(Uuid, TimeRelationship)>>,
37    /// Subtask tree: parent -> children.
38    #[builder(default)]
39    children: HashMap<Uuid, Vec<Uuid>>,
40    /// Subtask tree: child -> parent.
41    #[builder(default)]
42    parent_of: HashMap<Uuid, Uuid>,
43    /// The list of resources associated with the project.
44    #[builder(default)]
45    resources: Vec<Resource>,
46    /// The list of stakeholders associated with the project.
47    #[builder(default)]
48    stakeholders: Vec<Stakeholder>,
49}
50
51#[derive(Debug, Default, Clone, Copy)]
52/// The predecessor - successor relationship between tasks.
53pub enum TimeRelationship {
54    /// The predecessor has to start for the successor to finish.
55    StartToFinish,
56    /// The predecessor has to finish for the successor to finish.
57    FinishToFinish,
58    #[default]
59    /// The predecessor has to finish for the successor to start.
60    FinishToStart,
61    /// The predecessor has to start for the successor to start.
62    StartToStart,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66/// The direction of a relationship update.
67pub enum RelDir {
68    /// Update the predecessors of a task.
69    Predecessors,
70    /// Update the successors of a task.
71    Successors,
72}
73
74impl Project {
75    /// Creates a new project with the given name.
76    ///
77    /// # Arguments
78    ///
79    /// * `name` - The name of the project.
80    ///
81    /// # Returns
82    ///
83    /// A new `Project` instance.
84    ///
85    /// # Example
86    ///
87    /// ```
88    /// use planter_core::project::Project;
89    ///
90    /// let project = Project::new("World domination");
91    /// assert_eq!(project.name(), "World domination");
92    /// ```
93    #[must_use]
94    pub fn new(name: impl Into<String>) -> Self {
95        Self {
96            name: name.into(),
97            ..Default::default()
98        }
99    }
100
101    /// Returns the name of the project.
102    ///
103    /// # Example
104    ///
105    /// ```
106    /// use planter_core::project::Project;
107    ///
108    /// let project = Project::new("World domination");
109    /// assert_eq!(project.name(), "World domination");
110    /// ```
111    #[must_use]
112    pub fn name(&self) -> &str {
113        &self.name
114    }
115
116    /// Returns the description of the project.
117    ///
118    /// # Example
119    ///
120    /// ```
121    /// use planter_core::project::Project;
122    ///
123    /// let project = Project::new("World domination");
124    /// assert_eq!(project.description(), None);
125    /// ```
126    #[must_use]
127    pub fn description(&self) -> Option<&str> {
128        self.description.as_deref()
129    }
130
131    /// Adds a task to the project and returns its stable [`Uuid`].
132    ///
133    /// # Arguments
134    ///
135    /// * `task` - The task to add to the project.
136    ///
137    /// # Returns
138    ///
139    /// The stable [`Uuid`] assigned to the task.
140    ///
141    /// # Example
142    ///
143    /// ```
144    /// use planter_core::{project::Project, task::Task};
145    ///
146    /// let mut project = Project::new("World domination");
147    /// let id = project.add_task(Task::new("Become world leader"));
148    /// assert_eq!(project.tasks().count(), 1);
149    /// ```
150    pub fn add_task(&mut self, task: Task) -> Uuid {
151        let id = task.id();
152        self.tasks.insert(id, task);
153        self.task_order.push(id);
154        id
155    }
156
157    /// Inserts a new task as a sibling right before `sibling_id` in the task order.
158    /// If the sibling has a parent, the new task becomes a child of the same parent.
159    ///
160    /// # Example
161    ///
162    /// ```
163    /// use planter_core::{project::Project, task::Task};
164    ///
165    /// let mut project = Project::new("World domination");
166    /// let a = project.add_task(Task::new("Build an army"));
167    /// let b = project.add_task(Task::new("Train troops"));
168    /// let c = project.add_sibling_before(Task::new("Gather allies"), b);
169    ///
170    /// let ids: Vec<_> = project.tasks().map(|t| t.id()).collect();
171    /// assert_eq!(ids, vec![a, c, b]);
172    /// ```
173    pub fn add_sibling_before(&mut self, task: Task, sibling_id: Uuid) -> Uuid {
174        let id = task.id();
175        self.tasks.insert(id, task);
176        if let Some(pos) = self.task_order.iter().position(|&t| t == sibling_id) {
177            self.task_order.insert(pos, id);
178        } else {
179            self.task_order.push(id);
180        }
181        if let Some(&parent_id) = self.parent_of.get(&sibling_id) {
182            self.parent_of.insert(id, parent_id);
183            self.children.entry(parent_id).or_default().push(id);
184        }
185        id
186    }
187
188    /// Inserts a new task as a sibling right after `sibling_id` in the task order.
189    /// If the sibling has a parent, the new task becomes a child of the same parent.
190    ///
191    /// # Example
192    ///
193    /// ```
194    /// use planter_core::{project::Project, task::Task};
195    ///
196    /// let mut project = Project::new("World domination");
197    /// let a = project.add_task(Task::new("Build an army"));
198    /// let b = project.add_task(Task::new("Train troops"));
199    /// let c = project.add_sibling_after(Task::new("Gather allies"), a);
200    ///
201    /// let ids: Vec<_> = project.tasks().map(|t| t.id()).collect();
202    /// assert_eq!(ids, vec![a, c, b]);
203    /// ```
204    pub fn add_sibling_after(&mut self, task: Task, sibling_id: Uuid) -> Uuid {
205        let id = task.id();
206        self.tasks.insert(id, task);
207        if let Some(pos) = self.task_order.iter().position(|&t| t == sibling_id) {
208            self.task_order.insert(pos + 1, id);
209        } else {
210            self.task_order.push(id);
211        }
212        if let Some(&parent_id) = self.parent_of.get(&sibling_id) {
213            self.parent_of.insert(id, parent_id);
214            self.children.entry(parent_id).or_default().push(id);
215        }
216        id
217    }
218
219    /// Deletes a task and all references to it from the project.
220    ///
221    /// # Arguments
222    ///
223    /// * `id` - The [`Uuid`] of the task to remove.
224    ///
225    /// # Errors
226    /// Returns an error if the task doesn't exist.
227    ///
228    /// # Example
229    ///
230    /// ```
231    /// use planter_core::{project::Project, task::Task};
232    ///
233    /// let mut project = Project::new("World domination");
234    /// let id = project.add_task(Task::new("Become world leader"));
235    /// assert_eq!(project.tasks().count(), 1);
236    /// assert!(project.rm_task(id).is_ok());
237    /// assert_eq!(project.tasks().count(), 0);
238    /// ```
239    pub fn rm_task(&mut self, id: Uuid) -> anyhow::Result<Task> {
240        let task = self
241            .tasks
242            .remove(&id)
243            .context("Tried removing a non existing task")?;
244        self.task_order.retain(|t| *t != id);
245
246        // Remove all time relationships involving this task.
247        for (succ, _) in self.succ.remove(&id).into_iter().flatten() {
248            if let Some(preds) = self.pred.get_mut(&succ) {
249                preds.retain(|(p, _)| *p != id);
250            }
251        }
252        for (pred_, _) in self.pred.remove(&id).into_iter().flatten() {
253            if let Some(succs) = self.succ.get_mut(&pred_) {
254                succs.retain(|(s, _)| *s != id);
255            }
256        }
257
258        // Remove subtask relationships.
259        if let Some(parent) = self.parent_of.remove(&id)
260            && let Some(children) = self.children.get_mut(&parent)
261        {
262            children.retain(|c| *c != id);
263        }
264        self.children.remove(&id);
265
266        Ok(task)
267    }
268
269    /// Gets a reference to the task with the given [`Uuid`].
270    ///
271    /// # Example
272    ///
273    /// ```
274    /// use planter_core::{project::Project, task::Task};
275    ///
276    /// let mut project = Project::new("World domination");
277    /// let id = project.add_task(Task::new("Become world leader"));
278    /// assert_eq!(project.task(id).unwrap().name(), "Become world leader");
279    /// ```
280    #[must_use]
281    pub fn task(&self, id: Uuid) -> Option<&Task> {
282        self.tasks.get(&id)
283    }
284
285    /// Gets a mutable reference to the task with the given [`Uuid`].
286    ///
287    /// # Example
288    ///
289    /// ```
290    /// use planter_core::{project::Project, task::Task};
291    ///
292    /// let mut project = Project::new("World domination");
293    /// let id = project.add_task(Task::new("Become world leader"));
294    /// let task = project.task_mut(id).unwrap();
295    /// assert_eq!(task.name(), "Become world leader");
296    ///
297    /// task.edit_name("Become world's biggest loser");
298    /// assert_eq!(task.name(), "Become world's biggest loser")
299    /// ```
300    #[must_use]
301    pub fn task_mut(&mut self, id: Uuid) -> Option<&mut Task> {
302        self.tasks.get_mut(&id)
303    }
304
305    /// Returns the tasks of the project in insertion order.
306    ///
307    /// # Example
308    ///
309    /// ```
310    /// use planter_core::{project::Project, task::Task};
311    ///
312    /// let mut project = Project::new("World domination");
313    /// project.add_task(Task::new("Become world leader"));
314    /// assert_eq!(project.tasks().count(), 1);
315    /// ```
316    pub fn tasks(&self) -> impl Iterator<Item = &Task> {
317        self.task_order.iter().filter_map(|id| self.tasks.get(id))
318    }
319
320    /// Returns a mutable iterator over the tasks.
321    ///
322    /// # Example
323    ///
324    /// ```
325    /// use planter_core::{project::Project, task::Task};
326    ///
327    /// let mut project = Project::new("World domination");
328    /// project.add_task(Task::new("Become world leader"));
329    /// assert_eq!(project.tasks_mut().count(), 1);
330    /// ```
331    pub fn tasks_mut(&mut self) -> impl Iterator<Item = &mut Task> {
332        self.tasks.values_mut()
333    }
334
335    /// Adds a relationship between tasks, where one is the predecessor and the other a successor.
336    ///
337    /// # Arguments
338    ///
339    /// * `predecessor` - The [`Uuid`] of the predecessor task.
340    /// * `successor` - The [`Uuid`] of the successor task.
341    /// * `kind` - The type of relationship.
342    ///
343    /// # Errors
344    /// Returns an error if either task doesn't exist, if the relationship would create a cycle,
345    /// or if the relationship already exists.
346    ///
347    /// # Example
348    ///
349    /// ```
350    /// use planter_core::{project::{Project, TimeRelationship}, task::Task};
351    ///
352    /// let mut project = Project::new("World domination");
353    /// let pred = project.add_task(Task::new("Get rich"));
354    /// let succ = project.add_task(Task::new("Become world leader"));
355    /// project.add_time_relationship(pred, succ, TimeRelationship::default());
356    ///
357    /// assert_eq!(project.successors(pred).next().unwrap().name(), "Become world leader")
358    /// ```
359    pub fn add_time_relationship(
360        &mut self,
361        predecessor: Uuid,
362        successor: Uuid,
363        kind: TimeRelationship,
364    ) -> anyhow::Result<()> {
365        if !self.tasks.contains_key(&predecessor) || !self.tasks.contains_key(&successor) {
366            bail!("Task not found");
367        }
368
369        if self
370            .succ
371            .get(&predecessor)
372            .map(|e| e.iter().any(|(s, _)| *s == successor))
373            .unwrap_or(false)
374        {
375            bail!("Relationship between tasks already exists");
376        }
377
378        if self.would_cycle(successor, predecessor) {
379            bail!("A cycle was detected between tasks {predecessor} and {successor}");
380        }
381
382        self.add_one_edge(predecessor, successor, kind);
383        Ok(())
384    }
385
386    /// Removes a relationship between tasks.
387    ///
388    /// # Arguments
389    ///
390    /// * `predecessor` - The [`Uuid`] of the predecessor task.
391    /// * `successor` - The [`Uuid`] of the successor task.
392    ///
393    /// # Errors
394    /// Returns an error if no relationship exists between the tasks.
395    ///
396    /// # Example
397    ///
398    /// ```
399    /// use planter_core::{project::{Project, TimeRelationship}, task::Task};
400    ///
401    /// let mut project = Project::new("World domination");
402    /// let pred = project.add_task(Task::new("Get rich"));
403    /// let succ = project.add_task(Task::new("Become world leader"));
404    /// project.add_time_relationship(pred, succ, TimeRelationship::default());
405    /// project.rm_time_relationship(pred, succ).unwrap();
406    ///
407    /// assert_eq!(project.successors(pred).count(), 0);
408    /// ```
409    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    /// Gets the successors of a given task.
427    ///
428    /// # Example
429    ///
430    /// ```
431    /// use planter_core::{project::{Project, TimeRelationship}, task::Task};
432    ///
433    /// let mut project = Project::new("World domination");
434    /// let pred = project.add_task(Task::new("Get rich"));
435    /// let succ = project.add_task(Task::new("Become world leader"));
436    /// project.add_time_relationship(pred, succ, TimeRelationship::default());
437    ///
438    /// assert_eq!(project.successors(pred).next().unwrap().name(), "Become world leader")
439    /// ```
440    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, _)| self.tasks.get(succ_id))
446    }
447
448    /// Gets the [`Uuid`]s of all successors for a given task.
449    ///
450    /// # Example
451    ///
452    /// ```
453    /// use planter_core::{project::{Project, TimeRelationship}, task::Task};
454    ///
455    /// let mut project = Project::new("World domination");
456    /// let pred = project.add_task(Task::new("Get rich"));
457    /// let succ = project.add_task(Task::new("Become world leader"));
458    /// project.add_time_relationship(pred, succ, TimeRelationship::default());
459    ///
460    /// assert_eq!(project.successors_ids(pred).next().unwrap(), succ)
461    /// ```
462    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    /// Gets the predecessors of a given task.
471    ///
472    /// # Example
473    ///
474    /// ```
475    /// use planter_core::{project::{Project, TimeRelationship}, task::Task};
476    ///
477    /// let mut project = Project::new("World domination");
478    /// let pred = project.add_task(Task::new("Get rich"));
479    /// let succ = project.add_task(Task::new("Become world leader"));
480    /// project.add_time_relationship(pred, succ, TimeRelationship::default());
481    ///
482    /// assert_eq!(project.predecessors(succ).next().unwrap().name(), "Get rich")
483    /// ```
484    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, _)| self.tasks.get(pred_id))
490    }
491
492    /// Gets the [`Uuid`]s of all predecessors for a given task.
493    ///
494    /// # Example
495    ///
496    /// ```
497    /// use planter_core::{project::{Project, TimeRelationship}, task::Task};
498    ///
499    /// let mut project = Project::new("World domination");
500    /// let pred = project.add_task(Task::new("Get rich"));
501    /// let succ = project.add_task(Task::new("Become world leader"));
502    /// project.add_time_relationship(pred, succ, TimeRelationship::default());
503    ///
504    /// assert_eq!(project.predecessors_ids(succ).next().unwrap(), pred)
505    /// ```
506    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    /// Sets the predecessors or successors of a task to exactly the given set of tasks.
515    ///
516    /// # Arguments
517    ///
518    /// * `task_id` - The [`Uuid`] of the task whose relationships need updating.
519    /// * `ids` - The tasks to set as predecessors or successors.
520    /// * `dir` - Whether to update predecessors or successors.
521    /// * `kind` - The type of time relationship.
522    ///
523    /// # Errors
524    ///
525    /// Returns an error if:
526    /// * Any task doesn't exist.
527    /// * The update would create a cycle.
528    ///
529    /// # Example
530    ///
531    /// ```
532    /// use planter_core::{project::{Project, RelDir, TimeRelationship}, task::Task};
533    ///
534    /// let mut project = Project::new("World domination");
535    /// let id0 = project.add_task(Task::new("Become world leader"));
536    /// let id1 = project.add_task(Task::new("Get rich"));
537    /// let id2 = project.add_task(Task::new("Be evil"));
538    ///
539    /// project.update_relationships(id2, &[id0, id1], RelDir::Predecessors, TimeRelationship::FinishToStart).unwrap();
540    /// assert_eq!(project.predecessors(id2).count(), 2);
541    /// ```
542    pub fn update_relationships(
543        &mut self,
544        task_id: Uuid,
545        ids: &[Uuid],
546        dir: RelDir,
547        kind: TimeRelationship,
548    ) -> anyhow::Result<()> {
549        if !self.tasks.contains_key(&task_id) {
550            bail!("Task {task_id} doesn't exist");
551        }
552        for &id in ids {
553            if !self.tasks.contains_key(&id) {
554                bail!("Task {id} doesn't exist");
555            }
556        }
557
558        let old: HashSet<Uuid> = match dir {
559            RelDir::Predecessors => self.predecessors_ids(task_id).collect(),
560            RelDir::Successors => self.successors_ids(task_id).collect(),
561        };
562        let new: HashSet<Uuid> = ids.iter().copied().collect();
563
564        let to_add: Vec<Uuid> = ids.iter().filter(|i| !old.contains(i)).copied().collect();
565        let to_remove: Vec<Uuid> = old.iter().filter(|i| !new.contains(i)).copied().collect();
566
567        let mut added = Vec::new();
568        for &i in &to_add {
569            let (pred, succ) = match dir {
570                RelDir::Predecessors => (i, task_id),
571                RelDir::Successors => (task_id, i),
572            };
573            if self.is_ancestor(pred, succ) || self.is_ancestor(succ, pred) {
574                for &(p, s) in &added {
575                    self.remove_one_edge(p, s);
576                }
577                bail!(
578                    "Cannot add a predecessor/successor relationship between an ancestor and a descendant"
579                );
580            }
581            if self.would_cycle(succ, pred) {
582                for &(p, s) in &added {
583                    self.remove_one_edge(p, s);
584                }
585                bail!("A cycle was detected between tasks {i} and {task_id}");
586            }
587            self.add_one_edge(pred, succ, kind);
588            added.push((pred, succ));
589        }
590
591        for &i in &to_remove {
592            let (pred, succ) = match dir {
593                RelDir::Predecessors => (i, task_id),
594                RelDir::Successors => (task_id, i),
595            };
596            self.remove_one_edge(pred, succ);
597        }
598
599        Ok(())
600    }
601
602    /// Moves `id` right after `after_id` in the global task order, affecting display order.
603    ///
604    /// # Example
605    ///
606    /// ```
607    /// use planter_core::{project::Project, task::Task};
608    ///
609    /// let mut project = Project::new("World domination");
610    /// let a = project.add_task(Task::new("Build an army"));
611    /// let b = project.add_task(Task::new("Train troops"));
612    /// let c = project.add_task(Task::new("Gather allies"));
613    /// project.move_task_after(c, a);
614    ///
615    /// let ids: Vec<_> = project.tasks().map(|t| t.id()).collect();
616    /// assert_eq!(ids, vec![a, c, b]);
617    /// ```
618    pub fn move_task_after(&mut self, id: Uuid, after_id: Uuid) {
619        let id_pos = self.task_order.iter().position(|&t| t == id);
620        let after_pos = self.task_order.iter().position(|&t| t == after_id);
621        if let (Some(ip), Some(ap)) = (id_pos, after_pos) {
622            self.task_order.remove(ip);
623            let insert_at = if ap > ip { ap } else { ap + 1 };
624            self.task_order.insert(insert_at, id);
625        }
626    }
627
628    /// Adds a subtask to a given task, marking the child as a component of the parent.
629    /// The parent task is completed when all children are completed.
630    ///
631    /// # Arguments
632    ///
633    /// * `parent_id` - The [`Uuid`] of the parent task.
634    /// * `child_id` - The [`Uuid`] of the child subtask.
635    ///
636    /// # Errors
637    ///
638    /// Returns an error if either task doesn't exist.
639    ///
640    /// # Example
641    ///
642    /// ```
643    /// use planter_core::{project::Project, task::Task};
644    ///
645    /// let mut project = Project::new("World domination");
646    /// let parent = project.add_task(Task::new("Build a house"));
647    /// let child1 = project.add_task(Task::new("Lay foundations"));
648    /// let child2 = project.add_task(Task::new("Build roof"));
649    ///
650    /// project.add_subtask(parent, child1).unwrap();
651    /// project.add_subtask(parent, child2).unwrap();
652    /// assert_eq!(project.subtasks(parent).count(), 2);
653    /// ```
654    pub fn add_subtask(&mut self, parent_id: Uuid, child_id: Uuid) -> anyhow::Result<()> {
655        if !self.tasks.contains_key(&parent_id) || !self.tasks.contains_key(&child_id) {
656            bail!("Task not found");
657        }
658        if parent_id == child_id {
659            bail!("A task cannot be a subtask of itself");
660        }
661        // No-op if already a child of this parent.
662        if self.parent_of.get(&child_id) == Some(&parent_id) {
663            return Ok(());
664        }
665        // Reject if parent is already a descendant of child (cycle).
666        let mut current = parent_id;
667        while let Some(&ancestor) = self.parent_of.get(&current) {
668            if ancestor == child_id {
669                bail!("Cannot make a task a subtask of one of its own descendants");
670            }
671            current = ancestor;
672        }
673        // Remove from any existing parent first.
674        if let Some(old_parent) = self.parent_of.remove(&child_id)
675            && let Some(children) = self.children.get_mut(&old_parent)
676        {
677            children.retain(|c| *c != child_id);
678            if children.is_empty() {
679                self.children.remove(&old_parent);
680            }
681        }
682        self.children.entry(parent_id).or_default().push(child_id);
683        self.parent_of.insert(child_id, parent_id);
684        Ok(())
685    }
686
687    /// Removes a subtask relationship, promoting the child back to a top-level task.
688    ///
689    /// # Errors
690    ///
691    /// Returns an error if the task is not a subtask.
692    ///
693    /// # Example
694    ///
695    /// ```
696    /// use planter_core::{project::Project, task::Task};
697    ///
698    /// let mut project = Project::new("World domination");
699    /// let army = project.add_task(Task::new("Build an army"));
700    /// let supplies = project.add_task(Task::new("Gather supplies"));
701    /// project.add_subtask(army, supplies).unwrap();
702    /// assert!(project.task_parent(supplies).is_some());
703    ///
704    /// project.remove_subtask(supplies).unwrap();
705    /// assert!(project.task_parent(supplies).is_none());
706    /// ```
707    pub fn remove_subtask(&mut self, child_id: Uuid) -> anyhow::Result<()> {
708        let parent = self
709            .parent_of
710            .remove(&child_id)
711            .context("Task is not a subtask")?;
712        if let Some(children) = self.children.get_mut(&parent) {
713            children.retain(|c| *c != child_id);
714            if children.is_empty() {
715                self.children.remove(&parent);
716            }
717        }
718        Ok(())
719    }
720
721    /// Returns the parent [`Uuid`] of a subtask, or `None` if the task is at root level.
722    ///
723    /// # Example
724    ///
725    /// ```
726    /// use planter_core::{project::Project, task::Task};
727    ///
728    /// let mut project = Project::new("World domination");
729    /// let army = project.add_task(Task::new("Build an army"));
730    /// let supplies = project.add_task(Task::new("Gather supplies"));
731    ///
732    /// assert!(project.task_parent(supplies).is_none());
733    ///
734    /// project.add_subtask(army, supplies).unwrap();
735    /// assert_eq!(project.task_parent(supplies), Some(army));
736    /// ```
737    pub fn task_parent(&self, child_id: Uuid) -> Option<Uuid> {
738        self.parent_of.get(&child_id).copied()
739    }
740
741    /// Gets the [`Uuid`]s of all subtasks of the given task.
742    ///
743    /// # Example
744    ///
745    /// ```
746    /// use planter_core::{project::Project, task::Task};
747    ///
748    /// let mut project = Project::new("World domination");
749    /// let parent = project.add_task(Task::new("Build a house"));
750    /// let child = project.add_task(Task::new("Lay foundations"));
751    /// assert_eq!(project.subtasks(parent).count(), 0);
752    ///
753    /// project.add_subtask(parent, child).unwrap();
754    /// assert_eq!(project.subtasks(parent).count(), 1);
755    /// ```
756    pub fn subtasks(&self, parent_id: Uuid) -> impl Iterator<Item = Uuid> + '_ {
757        self.children.get(&parent_id).into_iter().flatten().copied()
758    }
759
760    /// Expands the parent's start/finish dates to encompass all children.
761    /// Only ever expands outward — never contracts.
762    /// Has no effect if no child has start/finish dates.
763    ///
764    /// # Errors
765    ///
766    /// Returns an error if the parent task doesn't exist.
767    ///
768    /// # Example
769    ///
770    /// ```
771    /// use chrono::Utc;
772    /// use planter_core::{project::Project, task::Task};
773    ///
774    /// let mut project = Project::new("World domination");
775    /// let army = project.add_task(Task::new("Build an army"));
776    /// let supplies = project.add_task(Task::new("Gather supplies"));
777    /// project.add_subtask(army, supplies).unwrap();
778    ///
779    /// let now = Utc::now();
780    /// project.task_mut(supplies).unwrap().edit_start(now).unwrap();
781    /// project.sync_parent_dates(army).unwrap();
782    ///
783    /// assert_eq!(project.task(army).unwrap().start(), Some(now));
784    /// ```
785    pub fn sync_parent_dates(&mut self, parent_id: Uuid) -> anyhow::Result<()> {
786        let earliest_start = self
787            .subtasks(parent_id)
788            .filter_map(|child_id| self.task(child_id).and_then(|t| t.start()))
789            .min();
790        let latest_finish = self
791            .subtasks(parent_id)
792            .filter_map(|child_id| self.task(child_id).and_then(|t| t.finish()))
793            .max();
794
795        if earliest_start.is_none() && latest_finish.is_none() {
796            return Ok(());
797        }
798
799        let parent = self.task_mut(parent_id).context("Parent task not found")?;
800        if let Some(start) = earliest_start
801            && parent.start().is_none_or(|ps| start < ps)
802        {
803            let _ = parent.edit_start(start);
804        }
805        if let Some(finish) = latest_finish
806            && parent.finish().is_none_or(|pf| finish > pf)
807        {
808            let _ = parent.edit_finish(finish);
809        }
810        Ok(())
811    }
812
813    /// Returns the start date of the project.
814    ///
815    /// # Example
816    ///
817    /// ```
818    /// use planter_core::project::Project;
819    /// use chrono::Utc;
820    ///
821    /// let start_date = Utc::now();
822    /// let project = Project::builder().name("World domination").start_date(start_date).build();
823    /// assert_eq!(project.start_date(), Some(start_date));
824    /// ```
825    #[must_use]
826    pub const fn start_date(&self) -> Option<DateTime<Utc>> {
827        self.start_date
828    }
829
830    /// Adds a resource to the project.
831    ///
832    /// # Arguments
833    ///
834    /// * `resource` - The resource to add to the project.
835    ///
836    /// # Example
837    ///
838    /// ```
839    /// use planter_core::{resources::Resource, project::Project, person::Person};
840    ///
841    /// let mut project = Project::new("World domination");
842    /// project.add_resource(Resource::Personnel {
843    ///     person: Person::new("Sebastiano", "Giordano").unwrap(),
844    ///     hourly_rate: None,
845    /// });
846    /// assert_eq!(project.resources().len(), 1);
847    /// ```
848    pub fn add_resource(&mut self, resource: Resource) {
849        self.resources.push(resource);
850    }
851
852    /// Get a reference to a resource used in the project.
853    ///
854    /// # Example
855    ///
856    /// ```
857    /// use planter_core::{resources::Resource, project::Project, person::Person};
858    ///
859    /// let mut project = Project::new("World domination");
860    /// project.add_resource(Resource::Personnel {
861    ///     person: Person::new("Sebastiano", "Giordano").unwrap(),
862    ///     hourly_rate: None,
863    /// });
864    ///
865    /// assert!(project.resource(0).is_some());
866    /// ```
867    #[must_use]
868    pub fn resource(&self, index: usize) -> Option<&Resource> {
869        self.resources.get(index)
870    }
871
872    /// Remove a resource from the project.
873    ///
874    /// # Example
875    ///
876    /// ```
877    /// use planter_core::{resources::Resource, project::Project, person::Person};
878    ///
879    /// let mut project = Project::new("World domination");
880    /// project.add_resource(Resource::Personnel {
881    ///     person: Person::new("Sebastiano", "Giordano").unwrap(),
882    ///     hourly_rate: None,
883    /// });
884    ///
885    /// assert!(project.resource(0).is_some());
886    /// project.rm_resource(0);
887    /// assert!(project.resource(0).is_none());
888    /// assert!(project.rm_resource(0).is_none());
889    /// ```
890    #[must_use]
891    pub fn rm_resource(&mut self, index: usize) -> Option<Resource> {
892        if index < self.resources.len() {
893            Some(self.resources.remove(index))
894        } else {
895            None
896        }
897    }
898
899    /// Get a mutable reference to a resource used in the project.
900    ///
901    /// # Example
902    ///
903    /// ```
904    /// use planter_core::{resources::Resource, project::Project, person::Person};
905    ///
906    /// let mut project = Project::new("World domination");
907    /// project.add_resource(Resource::Personnel {
908    ///     person: Person::new("Sebastiano", "Giordano").unwrap(),
909    ///     hourly_rate: None,
910    /// });
911    ///
912    /// let resource = project.resource_mut(0).unwrap();
913    /// match resource {
914    ///   Resource::Material(_) => panic!(),
915    ///   Resource::Personnel {
916    ///     person,
917    ///     ..
918    ///   } => {
919    ///     person.update_first_name("Alessandro");
920    ///     assert_eq!(person.first_name(), "Alessandro");
921    ///   }
922    /// }
923    /// ```
924    #[must_use]
925    pub fn resource_mut(&mut self, index: usize) -> Option<&mut Resource> {
926        self.resources.get_mut(index)
927    }
928
929    /// Returns a reference to the list of resources associated with the project.
930    ///
931    /// # Example
932    ///
933    /// ```
934    /// use planter_core::{resources::{Resource, Material, NonConsumable}, project::Project};
935    ///
936    /// let mut project = Project::new("World domination");
937    /// project.add_resource(Resource::Material(Material::NonConsumable(
938    ///    NonConsumable::new("Crowbar"),
939    /// )));
940    /// assert_eq!(project.resources().len(), 1);
941    /// ```
942    #[must_use]
943    pub fn resources(&self) -> &[Resource] {
944        &self.resources
945    }
946
947    /// Converts a resource into a `Consumable`, if that's possible.
948    ///
949    /// # Arguments
950    ///
951    /// * resource_index - The index of a `Material`, that's not a `Consumable`
952    ///
953    /// # Errors
954    ///
955    /// * `ResourceConversionError::ResourceNotFound` - If a resource with the specified index does not exist.
956    /// * `ResourceConversionError::ConversionNotPossible` - When trying to convert a resource that's not a `Material`.
957    ///
958    /// # Example
959    ///
960    /// ```
961    /// use planter_core::{resources::{Resource, Material, NonConsumable}, project::Project};
962    ///
963    /// let mut project = Project::new("World domination");
964    /// project.add_resource(Resource::Material(Material::NonConsumable(
965    ///    NonConsumable::new("Crowbar"),
966    /// )));
967    /// assert!(project.res_into_consumable(0).is_ok());
968    /// ```
969    pub fn res_into_consumable(
970        &mut self,
971        resource_index: usize,
972    ) -> Result<(), ResourceConversionError> {
973        self.convert_resource(resource_index, true)
974    }
975
976    /// Converts a resource into a `NonConsumable`, if that's possible.
977    ///
978    /// # Arguments
979    ///
980    /// * resource_index - The index of a `Material`, that's not a `NonConsumable`
981    ///
982    /// # Errors
983    ///
984    /// * `ResourceConversionError::ResourceNotFound` - If a resource with the specified index does not exist.
985    /// * `ResourceConversionError::ConversionNotPossible` - When trying to convert a resource that's not a `Material`.
986    ///
987    /// # Example
988    ///
989    /// ```
990    /// use planter_core::{resources::{Resource, Material, Consumable}, project::Project};
991    ///
992    /// let mut project = Project::new("World domination");
993    /// project.add_resource(Resource::Material(Material::Consumable(
994    ///    Consumable::new("Stimpack"),
995    /// )));
996    /// assert!(project.res_into_nonconsumable(0).is_ok());
997    /// ```
998    pub fn res_into_nonconsumable(
999        &mut self,
1000        resource_index: usize,
1001    ) -> Result<(), ResourceConversionError> {
1002        self.convert_resource(resource_index, false)
1003    }
1004
1005    fn convert_resource(
1006        &mut self,
1007        resource_index: usize,
1008        to_consumable: bool,
1009    ) -> Result<(), ResourceConversionError> {
1010        let res = self
1011            .resources
1012            .get_mut(resource_index)
1013            .ok_or(ResourceConversionError::ResourceNotFound)?;
1014        let replacement = match res {
1015            Resource::Material(Material::NonConsumable(nc)) if to_consumable => {
1016                Some(Resource::Material(Material::Consumable(nc.clone().into())))
1017            }
1018            Resource::Material(Material::Consumable(c)) if !to_consumable => Some(
1019                Resource::Material(Material::NonConsumable(c.clone().into())),
1020            ),
1021            Resource::Material(Material::Consumable(_))
1022            | Resource::Material(Material::NonConsumable(_)) => None,
1023            _ => return Err(ResourceConversionError::ConversionNotPossible),
1024        };
1025        if let Some(r) = replacement {
1026            *res = r;
1027        }
1028        Ok(())
1029    }
1030
1031    /// Adds a stakeholder to the project.
1032    ///
1033    /// # Arguments
1034    ///
1035    /// * `stakeholder` - The stakeholder to add to the project.
1036    ///
1037    /// # Example
1038    ///
1039    /// ```
1040    /// use planter_core::{stakeholders::Stakeholder, project::Project, person::Person};
1041    ///
1042    /// let mut project = Project::new("World domination");
1043    /// let person = Person::new("Margherita", "Hack").unwrap();
1044    /// project.add_stakeholder(Stakeholder::Individual {
1045    ///   person,
1046    ///   description: None,
1047    /// });
1048    /// assert_eq!(project.stakeholders().len(), 1);
1049    /// ```
1050    pub fn add_stakeholder(&mut self, stakeholder: Stakeholder) {
1051        self.stakeholders.push(stakeholder);
1052    }
1053
1054    /// Returns a reference to the list of stakeholders associated with the project.
1055    ///
1056    /// # Example
1057    ///
1058    /// ```
1059    /// use planter_core::{stakeholders::Stakeholder, project::Project, person::Person};
1060    ///
1061    /// let mut project = Project::new("World domination");
1062    /// let person = Person::new("Margherita", "Hack").unwrap();
1063    /// project.add_stakeholder(Stakeholder::Individual {
1064    ///   person,
1065    ///   description: None,
1066    /// });
1067    /// assert_eq!(project.stakeholders().len(), 1);
1068    /// ```
1069    #[must_use]
1070    pub fn stakeholders(&self) -> &[Stakeholder] {
1071        &self.stakeholders
1072    }
1073
1074    /// Removes a stakeholder from the project by index.
1075    ///
1076    /// # Arguments
1077    ///
1078    /// * `index` - The index of the stakeholder to remove.
1079    ///
1080    /// # Returns
1081    ///
1082    /// The removed stakeholder, or `None` if the index is out of bounds.
1083    ///
1084    /// # Example
1085    ///
1086    /// ```
1087    /// use planter_core::{stakeholders::Stakeholder, project::Project, person::Person};
1088    ///
1089    /// let mut project = Project::new("World domination");
1090    /// let person = Person::new("Margherita", "Hack").unwrap();
1091    /// project.add_stakeholder(Stakeholder::Individual { person, description: None });
1092    /// assert_eq!(project.stakeholders().len(), 1);
1093    /// let removed = project.rm_stakeholder(0);
1094    /// assert!(removed.is_some());
1095    /// assert_eq!(project.stakeholders().len(), 0);
1096    /// ```
1097    #[must_use]
1098    pub fn rm_stakeholder(&mut self, index: usize) -> Option<Stakeholder> {
1099        if index < self.stakeholders.len() {
1100            Some(self.stakeholders.remove(index))
1101        } else {
1102            None
1103        }
1104    }
1105
1106    /// Returns `true` if `ancestor_id` is an ancestor of `descendant_id` in the task tree.
1107    fn is_ancestor(&self, ancestor_id: Uuid, descendant_id: Uuid) -> bool {
1108        let mut seen = HashSet::new();
1109        let mut current = descendant_id;
1110        while let Some(parent) = self.task_parent(current) {
1111            if !seen.insert(parent) {
1112                break;
1113            }
1114            if parent == ancestor_id {
1115                return true;
1116            }
1117            current = parent;
1118        }
1119        false
1120    }
1121
1122    /// BFS from `from` following successors. Returns `true` if `to` is reachable.
1123    fn would_cycle(&self, from: Uuid, to: Uuid) -> bool {
1124        let mut seen = HashSet::new();
1125        let mut q = VecDeque::new();
1126        q.push_back(from);
1127        while let Some(v) = q.pop_front() {
1128            if v == to {
1129                return true;
1130            }
1131            if seen.insert(v)
1132                && let Some(succs) = self.succ.get(&v)
1133            {
1134                for (s, _) in succs {
1135                    q.push_back(*s);
1136                }
1137            }
1138        }
1139        false
1140    }
1141
1142    fn add_one_edge(&mut self, pred: Uuid, succ: Uuid, kind: TimeRelationship) {
1143        self.succ.entry(pred).or_default().push((succ, kind));
1144        self.pred.entry(succ).or_default().push((pred, kind));
1145    }
1146
1147    fn remove_one_edge(&mut self, pred: Uuid, succ: Uuid) {
1148        if let Some(entries) = self.succ.get_mut(&pred) {
1149            entries.retain(|(s, _)| *s != succ);
1150            if entries.is_empty() {
1151                self.succ.remove(&pred);
1152            }
1153        }
1154        if let Some(entries) = self.pred.get_mut(&succ) {
1155            entries.retain(|(p, _)| *p != pred);
1156            if entries.is_empty() {
1157                self.pred.remove(&succ);
1158            }
1159        }
1160    }
1161}
1162
1163/// Represents an error that can occur when trying to convert `Material` resources variants to another variant.
1164#[derive(Error, Debug, PartialEq, Eq)]
1165pub enum ResourceConversionError {
1166    /// Used when trying to convert a resource with an index out of bounds.
1167    #[error("The resource with the specified index wasn't found")]
1168    ResourceNotFound,
1169    /// Used when trying to convert a resource that's not a material, for example personnel.
1170    #[error("Tried to convert a resource that's not a material")]
1171    ConversionNotPossible,
1172}
1173
1174#[cfg(test)]
1175/// Utilities to test `[Project]`
1176pub mod test_utils {
1177    use proptest::{collection, prelude::*};
1178
1179    use crate::task::{Task, test_utils::task_strategy};
1180
1181    use super::{Project, RelDir, TimeRelationship};
1182
1183    const MAX_TASKS: usize = 100;
1184    const MIN_TASKS: usize = 5;
1185
1186    /// Generate a random amount of randomly generated `[Tasks]`.
1187    pub fn tasks_strategy() -> impl Strategy<Value = Vec<Task>> {
1188        collection::vec(task_strategy(), MIN_TASKS..MAX_TASKS)
1189    }
1190
1191    /// Generate a random `[Project]` with a linear chain of time relationships.
1192    pub fn project_graph_strategy() -> impl Strategy<Value = Project> {
1193        (".*", tasks_strategy()).prop_map(|(n, tasks)| {
1194            let mut project = Project::builder().name(n).build();
1195            let mut ids = Vec::new();
1196            for task in tasks {
1197                ids.push(project.add_task(task));
1198            }
1199
1200            let mut previous = None;
1201            for &current in &ids {
1202                if let Some(prev) = previous {
1203                    project
1204                        .update_relationships(
1205                            prev,
1206                            &[current],
1207                            RelDir::Successors,
1208                            TimeRelationship::FinishToStart,
1209                        )
1210                        .unwrap();
1211                }
1212                previous = Some(current);
1213            }
1214            project
1215        })
1216    }
1217
1218    /// Generate a random `[Project]` with no time relationships.
1219    pub fn project_strategy() -> impl Strategy<Value = Project> {
1220        (".*", tasks_strategy()).prop_map(|(n, tasks)| {
1221            let mut project = Project::builder().name(n).build();
1222            for task in tasks {
1223                project.add_task(task);
1224            }
1225            project
1226        })
1227    }
1228}
1229
1230#[cfg(test)]
1231mod tests {
1232    use proptest::prelude::*;
1233    use rand::{RngExt, rng};
1234
1235    use chrono::Utc;
1236    use uuid::Uuid;
1237
1238    use crate::{
1239        person::Person,
1240        project::{
1241            Project, RelDir, ResourceConversionError, TimeRelationship,
1242            test_utils::{project_graph_strategy, project_strategy},
1243        },
1244        resources::{Consumable, Material, NonConsumable, Resource},
1245        stakeholders::Stakeholder,
1246        task::Task,
1247    };
1248
1249    fn task_ids(project: &Project) -> Vec<Uuid> {
1250        project.tasks().map(|t| t.id()).collect()
1251    }
1252
1253    proptest! {
1254        #[test]
1255        fn update_relationships_predecessor_rejects_circular_graphs(mut project in project_graph_strategy()) {
1256            let ids = task_ids(&project);
1257            if ids.len() < 2 { return Ok(()); }
1258            let last = *ids.last().unwrap();
1259            assert!(project.update_relationships(ids[0], &[last], RelDir::Predecessors, TimeRelationship::FinishToStart).is_err());
1260        }
1261
1262        #[test]
1263        fn update_relationships_rejects_circular_graphs(mut project in project_graph_strategy()) {
1264            let ids = task_ids(&project);
1265            if ids.len() < 2 { return Ok(()); }
1266            let last = *ids.last().unwrap();
1267            assert!(project.update_relationships(last, &[ids[0]], RelDir::Successors, TimeRelationship::FinishToStart).is_err());
1268        }
1269
1270        #[test]
1271        fn update_relationships_rejects_non_existent_ids(mut project in project_strategy()) {
1272            let ids = task_ids(&project);
1273            if ids.is_empty() { return Ok(()); }
1274            let fake = Uuid::new_v4();
1275            assert!(project.update_relationships(ids[0], &[fake], RelDir::Predecessors, TimeRelationship::FinishToStart).is_err());
1276            assert!(project.update_relationships(ids[0], &[fake], RelDir::Successors, TimeRelationship::FinishToStart).is_err());
1277        }
1278
1279        #[test]
1280        fn update_relationships_predecessor_removes_them_if_input_is_empty(mut project in project_strategy()) {
1281            let ids = task_ids(&project);
1282            if ids.len() < 2 { return Ok(()); }
1283            let mut rng = rng();
1284            let idx1 = rng.random_range(0..ids.len());
1285            let mut idx2 = idx1;
1286            while idx2 == idx1 {
1287                idx2 = rng.random_range(0..ids.len());
1288            }
1289
1290            project.update_relationships(ids[idx1], &[ids[idx2]], RelDir::Predecessors, TimeRelationship::FinishToStart).unwrap();
1291            project.update_relationships(ids[idx1], &[], RelDir::Predecessors, TimeRelationship::FinishToStart).unwrap();
1292
1293            assert_eq!(project.predecessors(ids[idx1]).count(), 0);
1294        }
1295
1296        #[test]
1297        fn update_relationships_predecessor_removes_ids_not_present_in_input(mut project in project_strategy()) {
1298            let ids = task_ids(&project);
1299            if ids.len() < 3 { return Ok(()); }
1300            let mut rng = rng();
1301            let idx1 = rng.random_range(0..ids.len());
1302            let mut idx2 = idx1;
1303            let mut idx3 = idx1;
1304            while idx2 == idx1 {
1305                idx2 = rng.random_range(0..ids.len());
1306            }
1307            while idx3 == idx1 || idx3 == idx2 {
1308                idx3 = rng.random_range(0..ids.len());
1309            }
1310
1311            project.update_relationships(ids[idx1], &[ids[idx2], ids[idx3]], RelDir::Predecessors, TimeRelationship::FinishToStart).unwrap();
1312            project.update_relationships(ids[idx1], &[ids[idx2]], RelDir::Predecessors, TimeRelationship::FinishToStart).unwrap();
1313
1314            let mut predecessors = project.predecessors(ids[idx1]);
1315            assert_eq!(predecessors.next().map(|t| t.name()), project.task(ids[idx2]).map(|t| t.name()));
1316            assert!(predecessors.next().is_none());
1317        }
1318
1319        #[test]
1320        fn update_relationships_predecessor_works(mut project in project_strategy()) {
1321            let ids = task_ids(&project);
1322            if ids.len() < 2 { return Ok(()); }
1323            let mut rng = rng();
1324            let idx1 = rng.random_range(0..ids.len());
1325            let mut idx2 = idx1;
1326            while idx2 == idx1 {
1327                idx2 = rng.random_range(0..ids.len());
1328            }
1329
1330            project.update_relationships(ids[idx1], &[ids[idx2]], RelDir::Predecessors, TimeRelationship::FinishToStart).unwrap();
1331
1332            assert_eq!(project.predecessors(ids[idx1]).count(), 1);
1333            assert_eq!(
1334                project.predecessors(ids[idx1]).next().map(|t| t.name()),
1335                project.task(ids[idx2]).map(|t| t.name())
1336            );
1337        }
1338
1339        #[test]
1340        fn update_relationships_works(mut project in project_strategy()) {
1341            let ids = task_ids(&project);
1342            if ids.len() < 2 { return Ok(()); }
1343            let mut rng = rng();
1344            let idx1 = rng.random_range(0..ids.len());
1345            let mut idx2 = idx1;
1346            while idx2 == idx1 {
1347                idx2 = rng.random_range(0..ids.len());
1348            }
1349
1350            project.update_relationships(ids[idx1], &[ids[idx2]], RelDir::Successors, TimeRelationship::FinishToStart).unwrap();
1351
1352            let mut successors = project.successors(ids[idx1]);
1353            assert_eq!(successors.next().map(|t| t.name()), project.task(ids[idx2]).map(|t| t.name()));
1354            assert!(successors.next().is_none());
1355        }
1356
1357        #[test]
1358        fn update_relationships_removes_them_if_input_is_empty(mut project in project_strategy()) {
1359            let ids = task_ids(&project);
1360            if ids.len() < 2 { return Ok(()); }
1361            let mut rng = rng();
1362            let idx1 = rng.random_range(0..ids.len());
1363            let mut idx2 = idx1;
1364            while idx2 == idx1 {
1365                idx2 = rng.random_range(0..ids.len());
1366            }
1367
1368            project.update_relationships(ids[idx1], &[ids[idx2]], RelDir::Successors, TimeRelationship::FinishToStart).unwrap();
1369            project.update_relationships(ids[idx1], &[], RelDir::Successors, TimeRelationship::FinishToStart).unwrap();
1370
1371            assert_eq!(project.successors(ids[idx1]).count(), 0);
1372        }
1373
1374        #[test]
1375        fn update_relationships_removes_ids_not_present_in_input(mut project in project_strategy()) {
1376            let ids = task_ids(&project);
1377            if ids.len() < 3 { return Ok(()); }
1378            let mut rng = rng();
1379            let idx1 = rng.random_range(0..ids.len());
1380            let mut idx2 = idx1;
1381            let mut idx3 = idx1;
1382            while idx2 == idx1 {
1383                idx2 = rng.random_range(0..ids.len());
1384            }
1385            while idx3 == idx1 || idx3 == idx2 {
1386                idx3 = rng.random_range(0..ids.len());
1387            }
1388
1389            project.update_relationships(ids[idx1], &[ids[idx2], ids[idx3]], RelDir::Successors, TimeRelationship::FinishToStart).unwrap();
1390            project.update_relationships(ids[idx1], &[ids[idx2]], RelDir::Successors, TimeRelationship::FinishToStart).unwrap();
1391
1392            let mut successors = project.successors(ids[idx1]);
1393            assert_eq!(successors.next().map(|t| t.name()), project.task(ids[idx2]).map(|t| t.name()));
1394            assert!(successors.next().is_none());
1395        }
1396    }
1397
1398    #[test]
1399    fn update_relationships_rolls_back_partial_additions_on_cycle() {
1400        let mut project = Project::new("test");
1401        let a = project.add_task(Task::new("A"));
1402        let b = project.add_task(Task::new("B"));
1403        let c = project.add_task(Task::new("C"));
1404        let d = project.add_task(Task::new("D"));
1405
1406        project
1407            .add_time_relationship(a, b, TimeRelationship::FinishToStart)
1408            .unwrap();
1409        project
1410            .add_time_relationship(b, c, TimeRelationship::FinishToStart)
1411            .unwrap();
1412        project
1413            .add_time_relationship(c, d, TimeRelationship::FinishToStart)
1414            .unwrap();
1415
1416        let old_preds: Vec<Uuid> = project.predecessors_ids(c).collect();
1417        assert_eq!(old_preds, vec![b]);
1418
1419        let result = project.update_relationships(
1420            c,
1421            &[a, d],
1422            RelDir::Predecessors,
1423            TimeRelationship::FinishToStart,
1424        );
1425        assert!(result.is_err());
1426
1427        let preds: Vec<Uuid> = project.predecessors_ids(c).collect();
1428        assert_eq!(
1429            preds,
1430            vec![b],
1431            "predecessors should be unchanged after rollback"
1432        );
1433        assert!(
1434            !project.predecessors_ids(c).any(|i| i == a),
1435            "partially-added edge a→c should have been rolled back"
1436        );
1437    }
1438
1439    #[test]
1440    fn update_relationships_handles_overlap() {
1441        let mut project = Project::new("test");
1442        let a = project.add_task(Task::new("A"));
1443        let b = project.add_task(Task::new("B"));
1444        let c = project.add_task(Task::new("C"));
1445        let d = project.add_task(Task::new("D"));
1446
1447        project
1448            .update_relationships(
1449                c,
1450                &[a, b],
1451                RelDir::Predecessors,
1452                TimeRelationship::FinishToStart,
1453            )
1454            .unwrap();
1455        let preds: Vec<Uuid> = project.predecessors_ids(c).collect();
1456        assert!(preds.contains(&a), "should contain a, got {preds:?}");
1457        assert!(preds.contains(&b), "should contain b, got {preds:?}");
1458
1459        project
1460            .update_relationships(
1461                c,
1462                &[b, d],
1463                RelDir::Predecessors,
1464                TimeRelationship::FinishToStart,
1465            )
1466            .unwrap();
1467        let preds: Vec<Uuid> = project.predecessors_ids(c).collect();
1468        assert!(preds.contains(&b));
1469        assert!(preds.contains(&d));
1470        assert!(!preds.contains(&a));
1471    }
1472
1473    #[test]
1474    fn res_into_consumable_returns_the_correct_errors() {
1475        let mut project = Project::new("World domination");
1476
1477        project.add_resource(Resource::Personnel {
1478            person: Person::new("Sebastiano", "Giordano").unwrap(),
1479            hourly_rate: None,
1480        });
1481
1482        assert_eq!(
1483            project.res_into_consumable(0),
1484            Err(ResourceConversionError::ConversionNotPossible)
1485        );
1486
1487        assert_eq!(
1488            project.res_into_consumable(1),
1489            Err(ResourceConversionError::ResourceNotFound)
1490        );
1491
1492        project.add_resource(Resource::Material(Material::Consumable(Consumable::new(
1493            "Stimpack",
1494        ))));
1495
1496        assert!(project.res_into_consumable(1).is_ok());
1497
1498        if let Resource::Material(Material::Consumable(_)) = project.resources()[1] {
1499        } else {
1500            panic!("It changed the resource type");
1501        }
1502
1503        project.add_resource(Resource::Material(Material::NonConsumable(
1504            NonConsumable::new("Crowbar"),
1505        )));
1506        project.res_into_consumable(2).unwrap();
1507        if let Resource::Material(Material::Consumable(_)) = project.resources()[2] {
1508        } else {
1509            panic!("It didn't change the resource type");
1510        }
1511    }
1512
1513    #[test]
1514    fn res_into_nonconsumable_returns_the_correct_errors() {
1515        let mut project = Project::new("World domination");
1516
1517        project.add_resource(Resource::Personnel {
1518            person: Person::new("Sebastiano", "Giordano").unwrap(),
1519            hourly_rate: None,
1520        });
1521
1522        assert_eq!(
1523            project.res_into_nonconsumable(0),
1524            Err(ResourceConversionError::ConversionNotPossible)
1525        );
1526
1527        assert_eq!(
1528            project.res_into_nonconsumable(1),
1529            Err(ResourceConversionError::ResourceNotFound)
1530        );
1531
1532        project.add_resource(Resource::Material(Material::NonConsumable(
1533            NonConsumable::new("Crowbar"),
1534        )));
1535
1536        assert!(project.res_into_nonconsumable(1).is_ok());
1537
1538        if let Resource::Material(Material::NonConsumable(_)) = project.resources()[1] {
1539        } else {
1540            panic!("It changed the resource type");
1541        }
1542
1543        project.add_resource(Resource::Material(Material::Consumable(Consumable::new(
1544            "Stimpack",
1545        ))));
1546        project.res_into_nonconsumable(2).unwrap();
1547        if let Resource::Material(Material::NonConsumable(_)) = project.resources()[2] {
1548        } else {
1549            panic!("It didn't change the resource type");
1550        }
1551    }
1552
1553    fn name_strategy() -> impl Strategy<Value = String> {
1554        r"[a-zA-Z0-9]{1,30}"
1555    }
1556
1557    proptest! {
1558        #[test]
1559        fn task_add_rm_lifecycle(mut project in project_strategy()) {
1560            let initial_count = project.tasks().count();
1561            let id = project.add_task(Task::new("new task"));
1562            assert_eq!(project.tasks().count(), initial_count + 1);
1563            project.rm_task(id).unwrap();
1564            assert_eq!(project.tasks().count(), initial_count);
1565            for task in project.tasks() {
1566                assert_ne!(task.name(), "new task");
1567            }
1568        }
1569
1570        #[test]
1571        fn rm_task_cleans_subtask_relationships(name in name_strategy()) {
1572            let mut project = Project::new(name);
1573            let parent = project.add_task(Task::new("parent"));
1574            let child = project.add_task(Task::new("child"));
1575            project.add_subtask(parent, child).unwrap();
1576            assert_eq!(project.subtasks(parent).collect::<Vec<_>>(), vec![child]);
1577            project.rm_task(parent).unwrap();
1578            assert!(project.subtasks(parent).next().is_none());
1579        }
1580
1581        #[test]
1582        fn resource_add_rm_lifecycle(name in name_strategy()) {
1583            let mut project = Project::new(name);
1584            assert_eq!(project.resources().len(), 0);
1585            project.add_resource(Resource::Material(Material::new("widget")));
1586            assert_eq!(project.resources().len(), 1);
1587            project.add_resource(Resource::Material(Material::new("gadget")));
1588            assert_eq!(project.resources().len(), 2);
1589            let removed = project.rm_resource(0).unwrap();
1590            assert!(matches!(removed, Resource::Material(ref m) if m.name() == "widget"));
1591            assert_eq!(project.resources().len(), 1);
1592            assert!(matches!(project.resources()[0], Resource::Material(ref m) if m.name() == "gadget"));
1593        }
1594
1595        #[test]
1596        fn stakeholder_add_increases_count(name in name_strategy(), first in "[a-zA-Z]{1,50}", last in "[a-zA-Z]{1,50}") {
1597            let mut project = Project::new(name);
1598            let p = Person::new(&first, &last).unwrap();
1599            project.add_stakeholder(Stakeholder::Individual { person: p, description: None });
1600            assert_eq!(project.stakeholders().len(), 1);
1601        }
1602
1603        #[test]
1604        fn rm_stakeholder_removes_and_returns(name in name_strategy(), first in "[a-zA-Z]{1,50}", last in "[a-zA-Z]{1,50}") {
1605            let mut project = Project::new(name);
1606            let p = Person::new(&first, &last).unwrap();
1607            project.add_stakeholder(Stakeholder::Individual { person: p.clone(), description: None });
1608            assert_eq!(project.stakeholders().len(), 1);
1609            let removed = project.rm_stakeholder(0);
1610            assert!(removed.is_some());
1611            assert_eq!(project.stakeholders().len(), 0);
1612            assert!(project.rm_stakeholder(0).is_none());
1613        }
1614
1615        #[test]
1616        fn add_time_relationship_works(mut project in project_strategy()) {
1617            let ids = task_ids(&project);
1618            if ids.len() < 2 { return Ok(()); }
1619            let mut rng = rand::rng();
1620            let p = rng.random_range(0..ids.len());
1621            let mut s = p;
1622            while s == p {
1623                s = rng.random_range(0..ids.len());
1624            }
1625
1626            project.add_time_relationship(ids[p], ids[s], TimeRelationship::FinishToStart).unwrap();
1627            let succs: Vec<_> = project.successors_ids(ids[p]).collect();
1628            assert!(succs.contains(&ids[s]), "successors({}) should contain {}", ids[p], ids[s]);
1629            let preds: Vec<_> = project.predecessors_ids(ids[s]).collect();
1630            assert!(preds.contains(&ids[p]), "predecessors({}) should contain {}", ids[s], ids[p]);
1631        }
1632
1633        #[test]
1634        fn add_time_relationship_rejects_duplicate(mut project in project_strategy()) {
1635            let ids = task_ids(&project);
1636            if ids.len() < 2 { return Ok(()); }
1637            let mut rng = rand::rng();
1638            let p = rng.random_range(0..ids.len());
1639            let mut s = p;
1640            while s == p {
1641                s = rng.random_range(0..ids.len());
1642            }
1643
1644            project.add_time_relationship(ids[p], ids[s], TimeRelationship::FinishToStart).unwrap();
1645            assert!(
1646                project.add_time_relationship(ids[p], ids[s], TimeRelationship::FinishToStart).is_err(),
1647                "duplicate edge should be rejected"
1648            );
1649        }
1650
1651        #[test]
1652        fn rm_time_relationship_works(mut project in project_strategy()) {
1653            let ids = task_ids(&project);
1654            if ids.len() < 2 { return Ok(()); }
1655            let mut rng = rand::rng();
1656            let p = rng.random_range(0..ids.len());
1657            let mut s = p;
1658            while s == p {
1659                s = rng.random_range(0..ids.len());
1660            }
1661
1662            project.add_time_relationship(ids[p], ids[s], TimeRelationship::FinishToStart).unwrap();
1663            project.rm_time_relationship(ids[p], ids[s]).unwrap();
1664            let succs: Vec<_> = project.successors_ids(ids[p]).collect();
1665            assert!(!succs.contains(&ids[s]), "successors({}) should not contain {}", ids[p], ids[s]);
1666        }
1667
1668        #[test]
1669        fn add_subtask_works(mut project in project_strategy()) {
1670            let ids = task_ids(&project);
1671            if ids.len() < 2 { return Ok(()); }
1672            let mut rng = rand::rng();
1673            let p = rng.random_range(0..ids.len());
1674            let mut c = p;
1675            while c == p {
1676                c = rng.random_range(0..ids.len());
1677            }
1678
1679            project.add_subtask(ids[p], ids[c]).unwrap();
1680            assert!(project.subtasks(ids[p]).any(|s| s == ids[c]), "subtasks({}) should contain {}", ids[p], ids[c]);
1681        }
1682
1683        #[test]
1684        fn res_into_consumable_preserves_fields(res_name in name_strategy(), qty in 1u16..1000u16, cost in 1u16..1000u16) {
1685            let mut project = Project::new("project");
1686            let mut m = Material::NonConsumable(NonConsumable::new(res_name.clone()));
1687            m.update_quantity(qty);
1688            m.update_cost_per_unit(cost);
1689            project.add_resource(Resource::Material(m));
1690            project.res_into_consumable(0).unwrap();
1691            if let Resource::Material(ref m) = project.resources()[0] {
1692                assert_eq!(m.name(), res_name);
1693                assert_eq!(m.quantity(), Some(qty));
1694                assert_eq!(m.cost_per_unit(), Some(cost));
1695            } else {
1696                panic!("Expected Material");
1697            }
1698        }
1699
1700        #[test]
1701        fn res_into_nonconsumable_preserves_fields(res_name in name_strategy(), qty in 1u16..1000u16, cost in 1u16..1000u16) {
1702            let mut project = Project::new("project");
1703            let mut m = Material::Consumable(Consumable::new(res_name.clone()));
1704            m.update_quantity(qty);
1705            m.update_cost_per_unit(cost);
1706            project.add_resource(Resource::Material(m));
1707            project.res_into_nonconsumable(0).unwrap();
1708            if let Resource::Material(ref m) = project.resources()[0] {
1709                assert_eq!(m.name(), res_name);
1710                assert_eq!(m.quantity(), Some(qty));
1711                assert_eq!(m.cost_per_unit(), Some(cost));
1712            } else {
1713                panic!("Expected Material");
1714            }
1715        }
1716
1717        #[test]
1718        fn add_subtask_rejects_invalid_ids(name in name_strategy()) {
1719            let mut project = Project::new(name);
1720            let task = project.add_task(Task::new("only task"));
1721            let fake = Uuid::new_v4();
1722            assert!(project.subtasks(fake).next().is_none());
1723            assert!(project.add_subtask(fake, task).is_err());
1724            assert!(project.add_subtask(task, fake).is_err());
1725        }
1726
1727        #[test]
1728        fn rm_time_relationship_rejects_invalid_ids(mut project in project_strategy()) {
1729            let ids = task_ids(&project);
1730            if ids.is_empty() { return Ok(()); }
1731            let fake = Uuid::new_v4();
1732            assert!(project.rm_time_relationship(ids[0], fake).is_err());
1733            assert!(project.rm_time_relationship(fake, ids[0]).is_err());
1734        }
1735
1736        #[test]
1737        fn rm_task_rejects_invalid_id(name in name_strategy()) {
1738            let mut project = Project::new(name);
1739            let fake = Uuid::new_v4();
1740            assert!(project.rm_task(fake).is_err());
1741        }
1742
1743        #[test]
1744        fn res_into_consumable_on_personnel_returns_error(name in name_strategy()) {
1745            let mut project = Project::new(name);
1746            let p = Person::new("test", "person").unwrap();
1747            project.add_resource(Resource::Personnel { person: p, hourly_rate: None });
1748            assert_eq!(
1749                project.res_into_consumable(0),
1750                Err(ResourceConversionError::ConversionNotPossible)
1751            );
1752        }
1753
1754        #[test]
1755        fn res_into_nonconsumable_on_personnel_returns_error(name in name_strategy()) {
1756            let mut project = Project::new(name);
1757            let p = Person::new("test", "person").unwrap();
1758            project.add_resource(Resource::Personnel { person: p, hourly_rate: None });
1759            assert_eq!(
1760                project.res_into_nonconsumable(0),
1761                Err(ResourceConversionError::ConversionNotPossible)
1762            );
1763        }
1764    }
1765
1766    #[test]
1767    fn add_time_relationship_rejects_invalid_ids() {
1768        let mut project = Project::new("test");
1769        let task = project.add_task(Task::new("task"));
1770        let fake = Uuid::new_v4();
1771        assert!(
1772            project
1773                .add_time_relationship(fake, task, TimeRelationship::FinishToStart)
1774                .is_err()
1775        );
1776        assert!(
1777            project
1778                .add_time_relationship(task, fake, TimeRelationship::FinishToStart)
1779                .is_err()
1780        );
1781    }
1782
1783    #[test]
1784    fn add_sibling_before_falls_back_to_end_when_sibling_not_found() {
1785        let mut project = Project::new("World domination");
1786        let a = project.add_task(Task::new("Build an army"));
1787        let fake = Uuid::new_v4();
1788        let b = project.add_sibling_before(Task::new("Train troops"), fake);
1789
1790        let ids: Vec<Uuid> = project.tasks().map(|t| t.id()).collect();
1791        assert_eq!(ids, vec![a, b]);
1792    }
1793
1794    #[test]
1795    fn move_task_after_is_noop_for_nonexistent_ids() {
1796        let mut project = Project::new("World domination");
1797        let a = project.add_task(Task::new("Build an army"));
1798        let b = project.add_task(Task::new("Train troops"));
1799        let fake = Uuid::new_v4();
1800
1801        project.move_task_after(fake, a);
1802        assert_eq!(
1803            project.tasks().map(|t| t.id()).collect::<Vec<_>>(),
1804            vec![a, b]
1805        );
1806
1807        project.move_task_after(a, fake);
1808        assert_eq!(
1809            project.tasks().map(|t| t.id()).collect::<Vec<_>>(),
1810            vec![a, b]
1811        );
1812    }
1813
1814    #[test]
1815    fn remove_subtask_rejects_non_subtask() {
1816        let mut project = Project::new("World domination");
1817        let task = project.add_task(Task::new("Do something"));
1818        assert!(project.remove_subtask(task).is_err());
1819    }
1820
1821    proptest! {
1822        #[test]
1823        fn add_sibling_before_inserts_correctly(mut project in project_strategy()) {
1824            let ids = task_ids(&project);
1825            if ids.len() < 2 { return Ok(()); }
1826            let mut rng = rand::rng();
1827            let idx = rng.random_range(0..ids.len());
1828
1829            let sibling_id = ids[idx];
1830            let new_id = project.add_sibling_before(Task::new("Minion"), sibling_id);
1831
1832            let ordered_ids: Vec<Uuid> = project.tasks().map(|t| t.id()).collect();
1833            let new_pos = ordered_ids.iter().position(|&id| id == new_id).unwrap();
1834            let sibling_pos = ordered_ids.iter().position(|&id| id == sibling_id).unwrap();
1835            assert_eq!(new_pos, sibling_pos - 1);
1836        }
1837
1838        #[test]
1839        fn add_sibling_after_inserts_correctly(mut project in project_strategy()) {
1840            let ids = task_ids(&project);
1841            if ids.len() < 2 { return Ok(()); }
1842            let mut rng = rand::rng();
1843            let idx = rng.random_range(0..ids.len());
1844
1845            let sibling_id = ids[idx];
1846            let new_id = project.add_sibling_after(Task::new("Minion"), sibling_id);
1847
1848            let ordered_ids: Vec<Uuid> = project.tasks().map(|t| t.id()).collect();
1849            let new_pos = ordered_ids.iter().position(|&id| id == new_id).unwrap();
1850            let sibling_pos = ordered_ids.iter().position(|&id| id == sibling_id).unwrap();
1851            assert_eq!(new_pos, sibling_pos + 1);
1852        }
1853
1854        #[test]
1855        fn add_sibling_inherits_parent(mut project in project_strategy()) {
1856            let ids = task_ids(&project);
1857            if ids.len() < 3 { return Ok(()); }
1858            let mut rng = rand::rng();
1859            let parent_idx = rng.random_range(0..ids.len());
1860            let child_idx = rng.random_range(0..ids.len());
1861            if parent_idx == child_idx { return Ok(()); }
1862
1863            project.add_subtask(ids[parent_idx], ids[child_idx]).unwrap();
1864            let new_id = project.add_sibling_before(Task::new("Minion"), ids[child_idx]);
1865
1866            assert_eq!(project.task_parent(new_id), Some(ids[parent_idx]));
1867            let children: Vec<Uuid> = project.subtasks(ids[parent_idx]).collect();
1868            assert!(children.contains(&new_id));
1869        }
1870
1871        #[test]
1872        fn move_task_after_reorders_correctly(mut project in project_strategy()) {
1873            let ids = task_ids(&project);
1874            if ids.len() < 3 { return Ok(()); }
1875            let mut rng = rand::rng();
1876            let idx = rng.random_range(0..ids.len());
1877            let mut after_idx = rng.random_range(0..ids.len());
1878            while after_idx == idx {
1879                after_idx = rng.random_range(0..ids.len());
1880            }
1881
1882            project.move_task_after(ids[idx], ids[after_idx]);
1883            let new_ids: Vec<Uuid> = project.tasks().map(|t| t.id()).collect();
1884
1885            let task_pos = new_ids.iter().position(|&id| id == ids[idx]).unwrap();
1886            let after_pos = new_ids.iter().position(|&id| id == ids[after_idx]).unwrap();
1887            assert_eq!(task_pos, after_pos + 1);
1888        }
1889
1890        #[test]
1891        fn remove_subtask_promotes_to_top_level(mut project in project_strategy()) {
1892            let ids = task_ids(&project);
1893            if ids.len() < 2 { return Ok(()); }
1894            let mut rng = rand::rng();
1895            let parent_idx = rng.random_range(0..ids.len());
1896            let child_idx = rng.random_range(0..ids.len());
1897            if parent_idx == child_idx { return Ok(()); }
1898
1899            project.add_subtask(ids[parent_idx], ids[child_idx]).unwrap();
1900            assert!(project.task_parent(ids[child_idx]).is_some());
1901            project.remove_subtask(ids[child_idx]).unwrap();
1902
1903            assert!(project.task_parent(ids[child_idx]).is_none());
1904            assert!(project.subtasks(ids[parent_idx]).next().is_none());
1905        }
1906
1907        #[test]
1908        fn sync_parent_dates_expands_to_children(
1909            mut project in project_strategy(),
1910            start_offset in 0..1_000_000i64,
1911            finish_offset in 0..1_000_000i64,
1912        ) {
1913            let ids = task_ids(&project);
1914            if ids.len() < 3 { return Ok(()); }
1915            let mut rng = rand::rng();
1916            let parent_idx = rng.random_range(0..ids.len());
1917            let child1_idx = rng.random_range(0..ids.len());
1918            let child2_idx = rng.random_range(0..ids.len());
1919            if child1_idx == parent_idx || child2_idx == parent_idx || child1_idx == child2_idx {
1920                return Ok(());
1921            }
1922
1923            project.add_subtask(ids[parent_idx], ids[child1_idx]).unwrap();
1924            project.add_subtask(ids[parent_idx], ids[child2_idx]).unwrap();
1925
1926            let now = Utc::now();
1927            let child1_start = now - chrono::Duration::milliseconds(start_offset);
1928            let child2_finish = now + chrono::Duration::milliseconds(finish_offset);
1929            project.task_mut(ids[child1_idx]).unwrap().edit_start(child1_start).unwrap();
1930            project.task_mut(ids[child2_idx]).unwrap().edit_finish(child2_finish).unwrap();
1931
1932            project.sync_parent_dates(ids[parent_idx]).unwrap();
1933
1934            assert_eq!(project.task(ids[parent_idx]).unwrap().start(), Some(child1_start));
1935            assert_eq!(project.task(ids[parent_idx]).unwrap().finish(), Some(child2_finish));
1936        }
1937    }
1938
1939    #[test]
1940    fn sync_parent_dates_noop_when_children_have_no_dates() {
1941        let mut project = Project::new("World domination");
1942        let army = project.add_task(Task::new("Build an army"));
1943        let supplies = project.add_task(Task::new("Gather supplies"));
1944        project.add_subtask(army, supplies).unwrap();
1945
1946        let now = Utc::now();
1947        project.task_mut(army).unwrap().edit_start(now).unwrap();
1948
1949        project.sync_parent_dates(army).unwrap();
1950        assert_eq!(project.task(army).unwrap().start(), Some(now));
1951        assert!(project.task(army).unwrap().finish().is_none());
1952    }
1953}