Skip to main content

planter_core/
project.rs

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))]
21/// Represents a project with a name and a list of resources.
22pub struct Project {
23    /// The name of the project.
24    name: String,
25    /// The description of the project.
26    description: Option<String>,
27    /// The start date of the project.
28    start_date: Option<DateTime<Utc>>,
29    /// The end date of the project.
30    end_date: Option<DateTime<Utc>>,
31    /// The tasks associated with the project, in the order they were added.
32    #[builder(default)]
33    tasks: Vec<Task>,
34    /// Successor relationships for the time-relationship DAG.
35    #[builder(default)]
36    succ: HashMap<Uuid, Vec<(Uuid, TimeRelationship)>>,
37    /// Predecessor relationships for the time-relationship DAG.
38    #[builder(default)]
39    pred: HashMap<Uuid, Vec<(Uuid, TimeRelationship)>>,
40    /// Subtask tree: parent -> children.
41    #[builder(default)]
42    children: HashMap<Uuid, Vec<Uuid>>,
43    /// Subtask tree: child -> parent.
44    #[builder(default)]
45    parent_of: HashMap<Uuid, Uuid>,
46    /// The resources the project pays for, in the order they were added. One-time purchase
47    /// costs are recorded here; tasks engage them via [`Self::assign_resource`].
48    #[builder(default)]
49    resources: Vec<Resource>,
50    /// The list of stakeholders associated with the project.
51    #[builder(default)]
52    stakeholders: Vec<Stakeholder>,
53}
54
55#[derive(Debug, Default, Clone, Copy)]
56#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
57/// The predecessor - successor relationship between tasks.
58pub enum TimeRelationship {
59    /// The predecessor has to start for the successor to finish.
60    StartToFinish,
61    /// The predecessor has to finish for the successor to finish.
62    FinishToFinish,
63    #[default]
64    /// The predecessor has to finish for the successor to start.
65    FinishToStart,
66    /// The predecessor has to start for the successor to start.
67    StartToStart,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
72/// The direction of a relationship update.
73pub enum RelDir {
74    /// Update the predecessors of a task.
75    Predecessors,
76    /// Update the successors of a task.
77    Successors,
78}
79
80impl Project {
81    /// Creates a new project with the given name.
82    ///
83    /// # Arguments
84    ///
85    /// * `name` - The name of the project.
86    ///
87    /// # Returns
88    ///
89    /// A new `Project` instance.
90    ///
91    /// # Example
92    ///
93    /// ```
94    /// use planter_core::project::Project;
95    ///
96    /// let project = Project::new("World domination");
97    /// assert_eq!(project.name(), "World domination");
98    /// ```
99    #[must_use]
100    pub fn new(name: impl Into<String>) -> Self {
101        Self {
102            name: name.into(),
103            ..Default::default()
104        }
105    }
106
107    /// Returns the name of the project.
108    ///
109    /// # Example
110    ///
111    /// ```
112    /// use planter_core::project::Project;
113    ///
114    /// let project = Project::new("World domination");
115    /// assert_eq!(project.name(), "World domination");
116    /// ```
117    #[must_use]
118    pub fn name(&self) -> &str {
119        &self.name
120    }
121
122    /// Returns the description of the project.
123    ///
124    /// # Example
125    ///
126    /// ```
127    /// use planter_core::project::Project;
128    ///
129    /// let project = Project::new("World domination");
130    /// assert_eq!(project.description(), None);
131    /// ```
132    #[must_use]
133    pub fn description(&self) -> Option<&str> {
134        self.description.as_deref()
135    }
136
137    /// Adds a task to the project and returns its stable [`Uuid`].
138    ///
139    /// Adding a task whose id already exists in the project replaces it in place, without
140    /// duplicating its slot in [`Self::tasks`].
141    ///
142    /// # Arguments
143    ///
144    /// * `task` - The task to add to the project.
145    ///
146    /// # Returns
147    ///
148    /// The stable [`Uuid`] assigned to the task.
149    ///
150    /// # Example
151    ///
152    /// ```
153    /// use planter_core::{project::Project, task::Task};
154    ///
155    /// let mut project = Project::new("World domination");
156    /// let id = project.add_task(Task::new("Become world leader"));
157    /// assert_eq!(project.tasks().count(), 1);
158    /// ```
159    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    /// Inserts a new task as a sibling right before `sibling_id` in the task order.
166    /// If the sibling has a parent, the new task becomes a child of the same parent. This only
167    /// ever inserts a new task, it never moves an existing one.
168    ///
169    /// # Errors
170    ///
171    /// Returns an error if `sibling_id` doesn't exist, or if `task`'s id is already used by
172    /// another task in the project.
173    ///
174    /// # Example
175    ///
176    /// ```
177    /// use planter_core::{project::Project, task::Task};
178    ///
179    /// let mut project = Project::new("World domination");
180    /// let a = project.add_task(Task::new("Build an army"));
181    /// let b = project.add_task(Task::new("Train troops"));
182    /// let c = project.add_sibling_before(Task::new("Gather allies"), b).unwrap();
183    ///
184    /// let ids: Vec<_> = project.tasks().map(|t| t.id()).collect();
185    /// assert_eq!(ids, vec![a, c, b]);
186    /// ```
187    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    /// Inserts a new task as a sibling right after `sibling_id` in the task order.
197    /// If the sibling has a parent, the new task becomes a child of the same parent. This only
198    /// ever inserts a new task, it never moves an existing one.
199    ///
200    /// # Errors
201    ///
202    /// Returns an error if `sibling_id` doesn't exist, or if `task`'s id is already used by
203    /// another task in the project.
204    ///
205    /// # Example
206    ///
207    /// ```
208    /// use planter_core::{project::Project, task::Task};
209    ///
210    /// let mut project = Project::new("World domination");
211    /// let a = project.add_task(Task::new("Build an army"));
212    /// let b = project.add_task(Task::new("Train troops"));
213    /// let c = project.add_sibling_after(Task::new("Gather allies"), a).unwrap();
214    ///
215    /// let ids: Vec<_> = project.tasks().map(|t| t.id()).collect();
216    /// assert_eq!(ids, vec![a, c, b]);
217    /// ```
218    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    /// Deletes a task and all references to it from the project. Any direct
228    /// subtasks of the removed task are promoted to the removed task's own
229    /// parent (or to top-level, if it had none).
230    ///
231    /// # Arguments
232    ///
233    /// * `id` - The [`Uuid`] of the task to remove.
234    ///
235    /// # Errors
236    /// Returns an error if the task doesn't exist.
237    ///
238    /// # Example
239    ///
240    /// ```
241    /// use planter_core::{project::Project, task::Task};
242    ///
243    /// let mut project = Project::new("World domination");
244    /// let id = project.add_task(Task::new("Become world leader"));
245    /// assert_eq!(project.tasks().count(), 1);
246    /// assert!(project.rm_task(id).is_ok());
247    /// assert_eq!(project.tasks().count(), 0);
248    /// ```
249    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        // Remove all time relationships involving this task.
254        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        // Remove subtask relationships. `id`'s own parent link is dropped, and any direct
266        // children of `id` are promoted to `id`'s former parent (or to top-level if it had
267        // none), via the same [`Self::detach_child`]/[`Self::attach_child`] pair
268        // [`Self::add_subtask`] and [`Self::remove_subtask`] use.
269        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    /// Gets a reference to the task with the given [`Uuid`].
285    ///
286    /// # Example
287    ///
288    /// ```
289    /// use planter_core::{project::Project, task::Task};
290    ///
291    /// let mut project = Project::new("World domination");
292    /// let id = project.add_task(Task::new("Become world leader"));
293    /// assert_eq!(project.task(id).unwrap().name(), "Become world leader");
294    /// ```
295    #[must_use]
296    pub fn task(&self, id: Uuid) -> Option<&Task> {
297        identifiable::find(&self.tasks, id)
298    }
299
300    /// Internal handle used to implement the `edit_task_*` methods below and other mutations
301    /// that need direct field access. Not exposed publicly: every public edit goes through a
302    /// method here that knows what else (ancestor sync, and so on) needs to happen alongside it.
303    fn task_mut(&mut self, id: Uuid) -> Option<&mut Task> {
304        identifiable::find_mut(&mut self.tasks, id)
305    }
306
307    /// Returns the tasks of the project in insertion order.
308    ///
309    /// # Example
310    ///
311    /// ```
312    /// use planter_core::{project::Project, task::Task};
313    ///
314    /// let mut project = Project::new("World domination");
315    /// project.add_task(Task::new("Become world leader"));
316    /// assert_eq!(project.tasks().count(), 1);
317    /// ```
318    pub fn tasks(&self) -> impl Iterator<Item = &Task> {
319        self.tasks.iter()
320    }
321
322    /// Adds a relationship between tasks, where one is the predecessor and the other a successor.
323    ///
324    /// # Arguments
325    ///
326    /// * `predecessor` - The [`Uuid`] of the predecessor task.
327    /// * `successor` - The [`Uuid`] of the successor task.
328    /// * `kind` - The type of relationship.
329    ///
330    /// # Errors
331    /// Returns an error if either task doesn't exist, if the relationship already exists, if
332    /// the relationship would create a cycle, or if one task is a subtask ancestor/descendant
333    /// of the other.
334    ///
335    /// # Example
336    ///
337    /// ```
338    /// use planter_core::{project::{Project, TimeRelationship}, task::Task};
339    ///
340    /// let mut project = Project::new("World domination");
341    /// let pred = project.add_task(Task::new("Get rich"));
342    /// let succ = project.add_task(Task::new("Become world leader"));
343    /// project.add_time_relationship(pred, succ, TimeRelationship::default());
344    ///
345    /// assert_eq!(project.successors(pred).next().unwrap().name(), "Become world leader")
346    /// ```
347    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    /// Checks that adding a predecessor -> successor edge is legal: neither task is the other's
375    /// subtask ancestor/descendant, and the edge wouldn't create a cycle. Shared by
376    /// [`Self::add_time_relationship`] and [`Self::update_relationships`], which both add edges
377    /// one at a time and need the same guard before each.
378    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    /// 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, _)| identifiable::find(&self.tasks, *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, _)| identifiable::find(&self.tasks, *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    /// * Any id in `ids` is a subtask ancestor/descendant of `task_id`.
529    ///
530    /// # Example
531    ///
532    /// ```
533    /// use planter_core::{project::{Project, RelDir, TimeRelationship}, task::Task};
534    ///
535    /// let mut project = Project::new("World domination");
536    /// let id0 = project.add_task(Task::new("Become world leader"));
537    /// let id1 = project.add_task(Task::new("Get rich"));
538    /// let id2 = project.add_task(Task::new("Be evil"));
539    ///
540    /// project.update_relationships(id2, &[id0, id1], RelDir::Predecessors, TimeRelationship::FinishToStart).unwrap();
541    /// assert_eq!(project.predecessors(id2).count(), 2);
542    /// ```
543    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    /// Moves `id` right after `after_id` in the global task order, affecting display order.
596    ///
597    /// # Errors
598    ///
599    /// Returns an error if `id` or `after_id` doesn't exist, or if they're the same task (there's
600    /// nothing to move relative to).
601    ///
602    /// # Example
603    ///
604    /// ```
605    /// use planter_core::{project::Project, task::Task};
606    ///
607    /// let mut project = Project::new("World domination");
608    /// let a = project.add_task(Task::new("Build an army"));
609    /// let b = project.add_task(Task::new("Train troops"));
610    /// let c = project.add_task(Task::new("Gather allies"));
611    /// project.move_task_after(c, a).unwrap();
612    ///
613    /// let ids: Vec<_> = project.tasks().map(|t| t.id()).collect();
614    /// assert_eq!(ids, vec![a, c, b]);
615    /// ```
616    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    /// Adds a subtask to a given task, marking the child as a component of the parent.
621    /// The parent task is completed when all children are completed.
622    ///
623    /// # Arguments
624    ///
625    /// * `parent_id` - The [`Uuid`] of the parent task.
626    /// * `child_id` - The [`Uuid`] of the child subtask.
627    ///
628    /// # Errors
629    ///
630    /// Returns an error if either task doesn't exist.
631    ///
632    /// # Example
633    ///
634    /// ```
635    /// use planter_core::{project::Project, task::Task};
636    ///
637    /// let mut project = Project::new("World domination");
638    /// let parent = project.add_task(Task::new("Build a house"));
639    /// let child1 = project.add_task(Task::new("Lay foundations"));
640    /// let child2 = project.add_task(Task::new("Build roof"));
641    ///
642    /// project.add_subtask(parent, child1).unwrap();
643    /// project.add_subtask(parent, child2).unwrap();
644    /// assert_eq!(project.subtasks(parent).count(), 2);
645    /// ```
646    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        // No-op if already a child of this parent.
656        if self.parent_of.get(&child_id) == Some(&parent_id) {
657            return Ok(());
658        }
659        // Reject if parent is already a descendant of child (cycle).
660        let mut current = parent_id;
661        while let Some(&ancestor) = self.parent_of.get(&current) {
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        // Remove from any existing parent first.
668        self.detach_child(child_id);
669        self.attach_child(parent_id, child_id);
670        Ok(())
671    }
672
673    /// Removes `child_id`'s parent link, if it has one, and cleans up the former parent's
674    /// children list too (dropping the entry entirely once it's empty). Returns the former
675    /// parent, or `None` if `child_id` wasn't anyone's subtask.
676    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    /// Records `child_id` as a subtask of `parent_id`. Doesn't check for an existing parent
688    /// link; call [`Self::detach_child`] first if `child_id` might already have one.
689    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    /// Removes a subtask relationship, promoting the child back to a top-level task.
695    ///
696    /// # Errors
697    ///
698    /// Returns an error if the task is not a subtask.
699    ///
700    /// # Example
701    ///
702    /// ```
703    /// use planter_core::{project::Project, task::Task};
704    ///
705    /// let mut project = Project::new("World domination");
706    /// let army = project.add_task(Task::new("Build an army"));
707    /// let supplies = project.add_task(Task::new("Gather supplies"));
708    /// project.add_subtask(army, supplies).unwrap();
709    /// assert!(project.task_parent(supplies).is_some());
710    ///
711    /// project.remove_subtask(supplies).unwrap();
712    /// assert!(project.task_parent(supplies).is_none());
713    /// ```
714    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    /// Returns the parent [`Uuid`] of a subtask, or `None` if the task is at root level.
721    ///
722    /// # Example
723    ///
724    /// ```
725    /// use planter_core::{project::Project, task::Task};
726    ///
727    /// let mut project = Project::new("World domination");
728    /// let army = project.add_task(Task::new("Build an army"));
729    /// let supplies = project.add_task(Task::new("Gather supplies"));
730    ///
731    /// assert!(project.task_parent(supplies).is_none());
732    ///
733    /// project.add_subtask(army, supplies).unwrap();
734    /// assert_eq!(project.task_parent(supplies), Some(army));
735    /// ```
736    pub fn task_parent(&self, child_id: Uuid) -> Option<Uuid> {
737        self.parent_of.get(&child_id).copied()
738    }
739
740    /// Gets the [`Uuid`]s of all subtasks of the given task.
741    ///
742    /// # Example
743    ///
744    /// ```
745    /// use planter_core::{project::Project, task::Task};
746    ///
747    /// let mut project = Project::new("World domination");
748    /// let parent = project.add_task(Task::new("Build a house"));
749    /// let child = project.add_task(Task::new("Lay foundations"));
750    /// assert_eq!(project.subtasks(parent).count(), 0);
751    ///
752    /// project.add_subtask(parent, child).unwrap();
753    /// assert_eq!(project.subtasks(parent).count(), 1);
754    /// ```
755    pub fn subtasks(&self, parent_id: Uuid) -> impl Iterator<Item = Uuid> + '_ {
756        self.children.get(&parent_id).into_iter().flatten().copied()
757    }
758
759    /// Expands `parent_id`'s own start/finish to encompass its direct children's. Only ever
760    /// expands outward, never contracts, and has no effect if no child has start/finish dates.
761    /// One level only: called by [`Self::sync_ancestors`] once per ancestor, walking up to the
762    /// root, so a chain of parents all end up covering their descendants' dates.
763    ///
764    /// # Errors
765    ///
766    /// Returns an error if `parent_id` doesn't exist.
767    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    /// Rolls a task's start/finish dates up through every ancestor, from its immediate parent to
796    /// the root. Called automatically by [`Self::edit_task_start`], [`Self::edit_task_finish`],
797    /// and [`Self::edit_task_duration`]; a top-level task with no parent is a no-op.
798    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            // Infallible here: `task_parent` only ever returns ids of tasks that exist.
802            let _ = self.sync_parent_dates(parent_id);
803            current = parent_id;
804        }
805    }
806
807    /// Sets a task's start date, then rolls the change up through every ancestor so each one's
808    /// own start/finish keeps covering all of its descendants. If the task has a finish date
809    /// earlier than `start`, the finish date is pulled forward to match it instead of leaving a
810    /// negative duration.
811    ///
812    /// # Errors
813    ///
814    /// Returns an error if `id` doesn't exist.
815    ///
816    /// # Example
817    ///
818    /// ```
819    /// use chrono::Utc;
820    /// use planter_core::{project::Project, task::Task};
821    ///
822    /// let mut project = Project::new("World domination");
823    /// let army = project.add_task(Task::new("Build an army"));
824    /// let supplies = project.add_task(Task::new("Gather supplies"));
825    /// project.add_subtask(army, supplies).unwrap();
826    ///
827    /// let now = Utc::now();
828    /// project.edit_task_start(supplies, now).unwrap();
829    ///
830    /// // `army`'s own start followed along automatically, no separate step needed.
831    /// assert_eq!(project.task(army).unwrap().start(), Some(now));
832    /// ```
833    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    /// Sets a task's finish date, then rolls the change up through every ancestor so each one's
842    /// own start/finish keeps covering all of its descendants. If the task has a start date
843    /// later than `finish`, the start date is pulled back to match it instead of leaving a
844    /// negative duration.
845    ///
846    /// # Errors
847    ///
848    /// Returns an error if `id` doesn't exist.
849    ///
850    /// # Example
851    ///
852    /// ```
853    /// use chrono::Utc;
854    /// use planter_core::{project::Project, task::Task};
855    ///
856    /// let mut project = Project::new("World domination");
857    /// let army = project.add_task(Task::new("Build an army"));
858    /// let supplies = project.add_task(Task::new("Gather supplies"));
859    /// project.add_subtask(army, supplies).unwrap();
860    ///
861    /// let now = Utc::now();
862    /// project.edit_task_finish(supplies, now).unwrap();
863    ///
864    /// assert_eq!(project.task(army).unwrap().finish(), Some(now));
865    /// ```
866    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    /// Sets a task's duration, then rolls the change up through every ancestor so each one's
875    /// own start/finish keeps covering all of its descendants.
876    ///
877    /// # Errors
878    ///
879    /// Returns an error if `id` doesn't exist.
880    ///
881    /// # Example
882    ///
883    /// ```
884    /// use chrono::Duration;
885    /// use planter_core::{project::Project, task::Task};
886    ///
887    /// let mut project = Project::new("World domination");
888    /// let task_id = project.add_task(Task::new("Build an army"));
889    /// project.edit_task_duration(task_id, Duration::hours(4).try_into().unwrap()).unwrap();
890    /// assert!(project.task(task_id).unwrap().duration().is_some());
891    /// ```
892    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    /// Edits a task's name.
905    ///
906    /// # Errors
907    ///
908    /// Returns an error if `id` doesn't exist.
909    ///
910    /// # Example
911    ///
912    /// ```
913    /// use planter_core::{project::Project, task::Task};
914    ///
915    /// let mut project = Project::new("World domination");
916    /// let id = project.add_task(Task::new("Become world leader"));
917    /// project.edit_task_name(id, "Become world's biggest loser").unwrap();
918    /// assert_eq!(project.task(id).unwrap().name(), "Become world's biggest loser");
919    /// ```
920    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    /// Edits a task's description.
926    ///
927    /// # Errors
928    ///
929    /// Returns an error if `id` doesn't exist.
930    ///
931    /// # Example
932    ///
933    /// ```
934    /// use planter_core::{project::Project, task::Task};
935    ///
936    /// let mut project = Project::new("World domination");
937    /// let id = project.add_task(Task::new("Become world leader"));
938    /// project.edit_task_description(id, "Step one of the plan").unwrap();
939    /// assert_eq!(project.task(id).unwrap().description(), Some("Step one of the plan"));
940    /// ```
941    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    /// Clears a task's description, setting it to `None`.
953    ///
954    /// # Errors
955    ///
956    /// Returns an error if `id` doesn't exist.
957    ///
958    /// # Example
959    ///
960    /// ```
961    /// use planter_core::{project::Project, task::Task};
962    ///
963    /// let mut project = Project::new("World domination");
964    /// let id = project.add_task(Task::new("Become world leader"));
965    /// project.edit_task_description(id, "Step one of the plan").unwrap();
966    ///
967    /// project.clear_task_description(id).unwrap();
968    /// assert!(project.task(id).unwrap().description().is_none());
969    /// ```
970    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    /// Toggles a task's completed status.
978    ///
979    /// # Errors
980    ///
981    /// Returns an error if `id` doesn't exist.
982    ///
983    /// # Example
984    ///
985    /// ```
986    /// use planter_core::{project::Project, task::Task};
987    ///
988    /// let mut project = Project::new("World domination");
989    /// let id = project.add_task(Task::new("Become world leader"));
990    /// assert!(!project.task(id).unwrap().completed());
991    ///
992    /// project.toggle_task_completed(id).unwrap();
993    /// assert!(project.task(id).unwrap().completed());
994    /// ```
995    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    /// Returns the start date of the project.
1003    ///
1004    /// # Example
1005    ///
1006    /// ```
1007    /// use planter_core::project::Project;
1008    /// use chrono::Utc;
1009    ///
1010    /// let start_date = Utc::now();
1011    /// let project = Project::builder().name("World domination").start_date(start_date).build();
1012    /// assert_eq!(project.start_date(), Some(start_date));
1013    /// ```
1014    #[must_use]
1015    pub const fn start_date(&self) -> Option<DateTime<Utc>> {
1016        self.start_date
1017    }
1018
1019    /// Returns the end date of the project.
1020    ///
1021    /// # Example
1022    ///
1023    /// ```
1024    /// use planter_core::project::Project;
1025    /// use chrono::Utc;
1026    ///
1027    /// let mut project = Project::new("World domination");
1028    /// assert!(project.end_date().is_none());
1029    /// let end_date = Utc::now();
1030    /// project.set_end_date(end_date);
1031    /// assert_eq!(project.end_date(), Some(end_date));
1032    /// ```
1033    #[must_use]
1034    pub const fn end_date(&self) -> Option<DateTime<Utc>> {
1035        self.end_date
1036    }
1037
1038    /// Sets the end date of the project.
1039    ///
1040    /// # Example
1041    ///
1042    /// ```
1043    /// use planter_core::project::Project;
1044    /// use chrono::Utc;
1045    ///
1046    /// let mut project = Project::new("World domination");
1047    /// let end_date = Utc::now();
1048    /// project.set_end_date(end_date);
1049    /// assert_eq!(project.end_date(), Some(end_date));
1050    /// ```
1051    pub const fn set_end_date(&mut self, end_date: DateTime<Utc>) {
1052        self.end_date = Some(end_date);
1053    }
1054
1055    /// Adds a resource to the project's pool and returns its stable [`Uuid`]. One-time purchase
1056    /// costs belong on the [`Resource`] itself (via [`Resource::add_purchase`]), recorded once
1057    /// regardless of how many tasks engage it via [`Self::assign_resource`].
1058    ///
1059    /// Adding a resource whose id already exists in the pool replaces it in place, without
1060    /// duplicating its slot in [`Self::resources`].
1061    ///
1062    /// # Arguments
1063    ///
1064    /// * `resource` - The resource to add to the project.
1065    ///
1066    /// # Example
1067    ///
1068    /// ```
1069    /// use planter_core::{resources::Resource, project::Project};
1070    ///
1071    /// let mut project = Project::new("World domination");
1072    /// project.add_resource(Resource::new("Stimpack".parse().unwrap()));
1073    /// assert_eq!(project.resources().count(), 1);
1074    /// ```
1075    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    /// Get a reference to a resource in the project, by its stable [`Uuid`].
1082    ///
1083    /// # Example
1084    ///
1085    /// ```
1086    /// use planter_core::{resources::Resource, project::Project};
1087    ///
1088    /// let mut project = Project::new("World domination");
1089    /// let id = project.add_resource(Resource::new("Stimpack".parse().unwrap()));
1090    ///
1091    /// assert!(project.resource(id).is_some());
1092    /// ```
1093    #[must_use]
1094    pub fn resource(&self, id: Uuid) -> Option<&Resource> {
1095        identifiable::find(&self.resources, id)
1096    }
1097
1098    /// Remove a resource from the project, by its stable [`Uuid`]. Any task assignments that
1099    /// referred to it are dropped too, so no task is left pointing at a resource that no longer
1100    /// exists. The returned `(task_id, quantity)` pairs record what those were, so a caller
1101    /// that wants to undo the removal can restore them with [`Self::assign_resource_units`].
1102    ///
1103    /// # Errors
1104    ///
1105    /// Returns an error if `id` doesn't refer to a resource in [`Self::resources`].
1106    ///
1107    /// # Example
1108    ///
1109    /// ```
1110    /// use planter_core::{resources::Resource, project::Project};
1111    ///
1112    /// let mut project = Project::new("World domination");
1113    /// let id = project.add_resource(Resource::new("Stimpack".parse().unwrap()));
1114    ///
1115    /// assert!(project.resource(id).is_some());
1116    /// project.rm_resource(id).unwrap();
1117    /// assert!(project.resource(id).is_none());
1118    /// assert!(project.rm_resource(id).is_err());
1119    /// ```
1120    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    /// Get a mutable reference to a resource in the project, by its stable [`Uuid`].
1132    ///
1133    /// # Example
1134    ///
1135    /// ```
1136    /// use planter_core::{resources::Resource, project::Project};
1137    ///
1138    /// let mut project = Project::new("World domination");
1139    /// let id = project.add_resource(Resource::new("Crobwar".parse().unwrap()));
1140    ///
1141    /// // Fixing a typo in a resource's title:
1142    /// project.resource_mut(id).unwrap().set_title("Crowbar".parse().unwrap());
1143    /// assert_eq!(project.resource(id).unwrap().title(), "Crowbar");
1144    /// ```
1145    #[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    /// Returns the list of resources in the project.
1151    ///
1152    /// # Example
1153    ///
1154    /// ```
1155    /// use planter_core::{resources::Resource, project::Project};
1156    ///
1157    /// let mut project = Project::new("World domination");
1158    /// project.add_resource(Resource::new("Crowbar".parse().unwrap()));
1159    /// assert_eq!(project.resources().count(), 1);
1160    /// ```
1161    pub fn resources(&self) -> impl Iterator<Item = &Resource> {
1162        self.resources.iter()
1163    }
1164
1165    /// Records a [`Purchase`] against a resource in the project's pool, returning its
1166    /// [`Purchase::id`]. The project-level counterpart to [`Resource::add_purchase`].
1167    ///
1168    /// # Errors
1169    ///
1170    /// Returns an error if `resource_id` doesn't refer to a resource in [`Self::resources`].
1171    ///
1172    /// # Example
1173    ///
1174    /// ```
1175    /// use planter_core::{resources::{Purchase, Resource}, project::Project, money::{Money, Currency}};
1176    ///
1177    /// let mut project = Project::new("Build");
1178    /// let stimpack = project.add_resource(Resource::new("Stimpack".parse().unwrap()));
1179    /// project.add_purchase(
1180    ///     stimpack,
1181    ///     Purchase::builder().quantity(10).unit_price(Money::from_minor_units(400, Currency::EUR)).build(),
1182    /// ).unwrap();
1183    /// assert_eq!(project.resource(stimpack).unwrap().purchases().count(), 1);
1184    /// ```
1185    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    /// Removes a resource's purchase by id, returning it. The project-level counterpart to
1193    /// [`Resource::rm_purchase`].
1194    ///
1195    /// # Errors
1196    ///
1197    /// Returns an error if `resource_id` doesn't refer to a resource in [`Self::resources`], or
1198    /// if that resource has no purchase with `purchase_id`.
1199    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    /// Mutable access to one of a resource's purchases, for editing it in place. The
1211    /// project-level counterpart to [`Resource::purchase_mut`].
1212    ///
1213    /// # Errors
1214    ///
1215    /// Returns an error if `resource_id` doesn't refer to a resource in [`Self::resources`], or
1216    /// if that resource has no purchase with `purchase_id`.
1217    ///
1218    /// # Example
1219    ///
1220    /// ```
1221    /// use planter_core::{resources::{Purchase, Resource}, project::Project, money::{Money, Currency}};
1222    ///
1223    /// let mut project = Project::new("Build");
1224    /// let stimpack = project.add_resource(Resource::new("Stimpack".parse().unwrap()));
1225    /// let purchase = project.add_purchase(
1226    ///     stimpack,
1227    ///     Purchase::builder().quantity(10).unit_price(Money::from_minor_units(400, Currency::EUR)).build(),
1228    /// ).unwrap();
1229    /// project.purchase_mut(stimpack, purchase).unwrap().set_unit_price(Money::from_minor_units(420, Currency::EUR));
1230    /// assert_eq!(
1231    ///     project.resource(stimpack).unwrap().purchases().next().unwrap().unit_price(),
1232    ///     Money::from_minor_units(420, Currency::EUR),
1233    /// );
1234    /// ```
1235    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    /// Records that `task_id` engages one unit of `resource_id`, checking that both the task and
1247    /// the resource exist in the project. For more than one unit, use
1248    /// [`Self::assign_resource_units`].
1249    ///
1250    /// A task engages any given resource at most once: a second call for the same resource
1251    /// replaces the quantity.
1252    ///
1253    /// # Errors
1254    ///
1255    /// Returns an error if `task_id` doesn't refer to a task in the project, or if
1256    /// `resource_id` doesn't refer to a resource in [`Self::resources`].
1257    ///
1258    /// # Example
1259    ///
1260    /// ```
1261    /// use planter_core::{project::Project, task::Task, resources::Resource};
1262    ///
1263    /// let mut project = Project::new("World domination");
1264    /// let task_id = project.add_task(Task::new("Find a crowbar"));
1265    /// let resource_id = project.add_resource(Resource::new("Crowbar".parse().unwrap()));
1266    ///
1267    /// project.assign_resource(task_id, resource_id).unwrap();
1268    /// assert_eq!(project.task(task_id).unwrap().assignments().count(), 1);
1269    /// ```
1270    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    /// Records that `task_id` engages `units` of `resource_id`, checking that both the task and
1275    /// the resource exist in the project.
1276    ///
1277    /// A task engages any given resource at most once: a second call for the same resource
1278    /// replaces the quantity. `units` is how many of the resource the task draws at once, e.g.
1279    /// 2 of a 4-person crew, or 3 units of a material. To remove an assignment, use
1280    /// [`Self::unassign_resource`] instead of trying to assign zero units.
1281    ///
1282    /// # Errors
1283    ///
1284    /// Returns an error if `task_id` doesn't refer to a task in the project, or if
1285    /// `resource_id` doesn't refer to a resource in [`Self::resources`].
1286    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    /// Removes `task_id`'s assignment for `resource_id`, returning the quantity it engaged. The
1302    /// counterpart to [`Self::assign_resource`].
1303    ///
1304    /// # Errors
1305    ///
1306    /// Returns an error if `task_id` doesn't refer to a task in the project, or if the task
1307    /// didn't engage `resource_id`.
1308    ///
1309    /// # Example
1310    ///
1311    /// ```
1312    /// use planter_core::{project::Project, task::Task, resources::Resource};
1313    /// use std::num::NonZeroU32;
1314    ///
1315    /// let mut project = Project::new("World domination");
1316    /// let task_id = project.add_task(Task::new("Find a stimpack"));
1317    /// let resource_id = project.add_resource(Resource::new("Stimpack".parse().unwrap()));
1318    /// project.assign_resource_units(task_id, resource_id, NonZeroU32::new(5).unwrap()).unwrap();
1319    ///
1320    /// assert_eq!(project.unassign_resource(task_id, resource_id).unwrap(), 5);
1321    /// assert!(project.unassign_resource(task_id, resource_id).is_err());
1322    /// assert_eq!(project.task(task_id).unwrap().assignments().count(), 0);
1323    /// ```
1324    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    /// Sums the project's total cost: every resource's one-time
1332    /// [`purchase_cost`](Resource::purchase_cost), plus, for every task, each assignment's
1333    /// hourly cost over the task's duration (see [`Self::task_cost`]).
1334    ///
1335    /// Each amount is priced in its resource's own currency; amounts in different currencies are
1336    /// kept as separate entries, never combined. Arithmetic saturates instead of overflowing.
1337    ///
1338    /// # Example
1339    ///
1340    /// ```
1341    /// use planter_core::{project::Project, task::Task};
1342    ///
1343    /// let mut project = Project::new("World domination");
1344    /// project.add_task(Task::new("Become world leader"));
1345    /// assert!(project.total_cost().is_empty());
1346    /// ```
1347    #[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    /// The cost of a single task: each assignment's `hourly_rate * hours * quantity`, priced in
1356    /// the resource's own currency and grouped into a [`MultiCurrencyAmount`]. A resource with
1357    /// no rate, or an assignment whose resource isn't in the project, contributes nothing.
1358    /// One-time purchase costs aren't counted here; those belong to the resource.
1359    ///
1360    /// # Errors
1361    ///
1362    /// Returns an error if `task_id` doesn't refer to a task in the project.
1363    ///
1364    /// # Example
1365    ///
1366    /// ```
1367    /// use planter_core::{project::Project, task::Task};
1368    ///
1369    /// let mut project = Project::new("World domination");
1370    /// let task_id = project.add_task(Task::new("Become world leader"));
1371    /// assert!(project.task_cost(task_id).unwrap().is_empty());
1372    /// ```
1373    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    /// The total a single resource has cost the project: its one-time
1381    /// [`purchase_cost`](Resource::purchase_cost) plus its hourly cost across every task that
1382    /// engages it.
1383    ///
1384    /// Summing this over every resource gives the same figure as [`Self::total_cost`].
1385    ///
1386    /// # Errors
1387    ///
1388    /// Returns an error if `resource_id` doesn't refer to a resource in [`Self::resources`].
1389    ///
1390    /// # Example
1391    ///
1392    /// ```
1393    /// use planter_core::{project::Project, task::Task, resources::{Purchase, Resource}, money::{Currency, Money}};
1394    /// use chrono::Duration;
1395    ///
1396    /// let mut project = Project::new("Build");
1397    /// let digger_id = project.add_resource(
1398    ///     Resource::new("Crowbar".parse().unwrap()).at_hourly_rate(Money::from_minor_units(30, Currency::EUR)),
1399    /// );
1400    /// project.add_purchase(
1401    ///     digger_id,
1402    ///     Purchase::builder().quantity(1).unit_price(Money::from_minor_units(1_000, Currency::EUR)).build(),
1403    /// ).unwrap();
1404    ///
1405    /// let task_id = project.add_task(Task::new("Dig"));
1406    /// project.edit_task_duration(task_id, Duration::hours(4).try_into().unwrap()).unwrap();
1407    /// project.assign_resource(task_id, digger_id).unwrap();
1408    ///
1409    /// // 1000 purchased + 30 * 4h used
1410    /// assert_eq!(
1411    ///     project.resource_cost(digger_id).unwrap().in_currency(Currency::EUR),
1412    ///     Some(Money::from_minor_units(1_120, Currency::EUR)),
1413    /// );
1414    /// ```
1415    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    /// Adds a stakeholder to the project.
1428    ///
1429    /// # Arguments
1430    ///
1431    /// * `stakeholder` - The stakeholder to add to the project.
1432    ///
1433    /// # Example
1434    ///
1435    /// ```
1436    /// use planter_core::{stakeholders::Stakeholder, project::Project, person::Person};
1437    ///
1438    /// let mut project = Project::new("World domination");
1439    /// let person = Person::new("Margherita", "Hack").unwrap();
1440    /// project.add_stakeholder(Stakeholder::Individual {
1441    ///   person,
1442    ///   description: None,
1443    /// });
1444    /// assert_eq!(project.stakeholders().len(), 1);
1445    /// ```
1446    pub fn add_stakeholder(&mut self, stakeholder: Stakeholder) {
1447        self.stakeholders.push(stakeholder);
1448    }
1449
1450    /// Returns a reference to the list of stakeholders associated with the project.
1451    ///
1452    /// # Example
1453    ///
1454    /// ```
1455    /// use planter_core::{stakeholders::Stakeholder, project::Project, person::Person};
1456    ///
1457    /// let mut project = Project::new("World domination");
1458    /// let person = Person::new("Margherita", "Hack").unwrap();
1459    /// project.add_stakeholder(Stakeholder::Individual {
1460    ///   person,
1461    ///   description: None,
1462    /// });
1463    /// assert_eq!(project.stakeholders().len(), 1);
1464    /// ```
1465    #[must_use]
1466    pub fn stakeholders(&self) -> &[Stakeholder] {
1467        &self.stakeholders
1468    }
1469
1470    /// Removes a stakeholder from the project by index.
1471    ///
1472    /// # Arguments
1473    ///
1474    /// * `index` - The index of the stakeholder to remove.
1475    ///
1476    /// # Returns
1477    ///
1478    /// The removed stakeholder, or `None` if the index is out of bounds.
1479    ///
1480    /// # Example
1481    ///
1482    /// ```
1483    /// use planter_core::{stakeholders::Stakeholder, project::Project, person::Person};
1484    ///
1485    /// let mut project = Project::new("World domination");
1486    /// let person = Person::new("Margherita", "Hack").unwrap();
1487    /// project.add_stakeholder(Stakeholder::Individual { person, description: None });
1488    /// assert_eq!(project.stakeholders().len(), 1);
1489    /// let removed = project.rm_stakeholder(0);
1490    /// assert!(removed.is_some());
1491    /// assert_eq!(project.stakeholders().len(), 0);
1492    /// ```
1493    #[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    /// Rejects adding a time relationship between `a` and `b` if one is a subtask
1503    /// ancestor/descendant of the other. Shared by [`Self::add_time_relationship`] and
1504    /// [`Self::update_relationships`] so both public entry points enforce the same invariant.
1505    ///
1506    /// # Errors
1507    /// Returns an error if `a` is an ancestor of `b`, or vice versa.
1508    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    /// Returns `true` if `ancestor_id` is an ancestor of `descendant_id` in the task tree.
1518    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    /// BFS from `from` following successors. Returns `true` if `to` is reachable.
1534    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)]
1575/// Utilities to test `[Project]`
1576pub 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    /// Generate a random amount of randomly generated `[Tasks]`.
1587    pub fn tasks_strategy() -> impl Strategy<Value = Vec<Task>> {
1588        collection::vec(task_strategy(), MIN_TASKS..MAX_TASKS)
1589    }
1590
1591    /// Generate a random `[Project]` with a linear chain of time relationships.
1592    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 &current 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    /// Generate a random `[Project]` with no time relationships.
1619    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    /// Shorthand for a non-zero assignment quantity in tests.
1652    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        // Unknown resource.
1887        assert!(project.assign_resource(task_id, Uuid::new_v4()).is_err());
1888        // Unknown task.
1889        assert!(
1890            project
1891                .assign_resource(Uuid::new_v4(), resource_id)
1892                .is_err()
1893        );
1894        // Both known.
1895        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        // Second call: task exists but no longer engages the resource.
1918        assert!(project.unassign_resource(task_id, id).is_err());
1919
1920        // Unknown task.
1921        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        // The assignment is untouched: it never encoded the title.
2039        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            // The removed task had no parent of its own, so its orphaned
2069            // child is promoted to top-level rather than left dangling.
2070            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            // A material's purchase cost is counted once, at the project level. A task drawing
2113            // on it adds nothing.
2114            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            // total_cost decomposes two ways, both equal to it.
2140            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}