Skip to main content

planter_core/
project.rs

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