Skip to main content

pinto/service/
dependency.rs

1//! PBI-to-PBI link services.
2
3use super::{open_board, open_board_locked};
4use crate::backlog::{BacklogItem, ItemId};
5use crate::error::{Error, Result};
6use crate::service::relations::validate_dependencies;
7use crate::storage::BacklogItemRepository;
8use chrono::Utc;
9use std::path::Path;
10
11/// Result of [`add_dependency`], including the updated PBI and any cycle warning.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct DependencyOutcome {
14    /// PBI with the updated dependency list.
15    pub item: BacklogItem,
16    /// Whether the new dependency creates a transitive cycle. Cycles are recorded but reported as
17    /// warnings rather than errors.
18    pub cycle_warning: bool,
19}
20
21/// Add dependency `dep` to the PBI `id` and return [`DependencyOutcome`].
22///
23/// The operation is idempotent: an existing dependency is not duplicated. Record cycles,
24/// including self-dependencies, and set `cycle_warning` to `true`. Return [`Error::NotInitialized`]
25/// for an uninitialized board or [`Error::NotFound`] when either ID is absent.
26pub async fn add_dependency(
27    project_dir: &Path,
28    id: &ItemId,
29    dep: &ItemId,
30) -> Result<DependencyOutcome> {
31    let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
32    let items = repo.list().await?;
33
34    if !items.iter().any(|it| &it.id == id) {
35        return Err(Error::NotFound(id.clone()));
36    }
37    // Validate the target and determine whether the new edge is a warning-only cycle.
38    let cycle_warning = validate_dependencies(&items, id, std::slice::from_ref(dep))?;
39
40    let mut item = repo.load(id).await?;
41    if !item.depends_on.contains(dep) {
42        item.depends_on.push(dep.clone());
43        item.updated = Utc::now();
44        repo.save(&item).await?;
45        repo.commit(&format!("pinto: update {}", item.id)).await?;
46    }
47    Ok(DependencyOutcome {
48        item,
49        cycle_warning,
50    })
51}
52
53/// Remove dependency `dep` from PBI `id` and return the saved [`BacklogItem`].
54///
55/// Return [`Error::NotFound`] when `dep` is not present or `id` does not exist, and
56/// [`Error::NotInitialized`] for an uninitialized board.
57pub async fn remove_dependency(
58    project_dir: &Path,
59    id: &ItemId,
60    dep: &ItemId,
61) -> Result<BacklogItem> {
62    let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
63    let mut item = repo.load(id).await?;
64    let before = item.depends_on.len();
65    item.depends_on.retain(|d| d != dep);
66    if item.depends_on.len() == before {
67        return Err(Error::NotFound(dep.clone()));
68    }
69    item.updated = Utc::now();
70    repo.save(&item).await?;
71    repo.commit(&format!("pinto: update {}", item.id)).await?;
72    Ok(item)
73}
74
75/// Result of [`item_detail`], with bidirectional links in ascending rank order.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct ItemDetail {
78    /// Target PBI; `parent` and `depends_on` are its forward links.
79    pub item: BacklogItem,
80    /// 1-based ordinal (for display only) among this PBI's siblings in the same
81    /// column, in ascending rank order.
82    ///
83    /// Rank is sibling-local: for a child it counts position among the parent's
84    /// children; for a top-level PBI, among the column's top-level PBIs. Dense
85    /// fractional index strings are unintuitive, so this gives "Nth under the
86    /// parent" / "Nth at top level". It is display-only and never persisted.
87    pub rank_ordinal: usize,
88    /// IDs of child items parented by this PBI (in ascending rank order).
89    pub children: Vec<ItemId>,
90    /// IDs of items that depend on this PBI (in ascending rank order).
91    pub dependents: Vec<ItemId>,
92}
93
94/// Returns the 1-based ordinal of `target` **among its siblings** in the same
95/// column, in ascending rank order.
96///
97/// Rank is sibling-local under the hierarchical ordering: a child is ranked
98/// only against the other children of its parent, and a top-level item against
99/// the other top-level items in the column. Siblings share the same `parent`
100/// (both `None`, or both the same id) and the same `status`. This keeps the
101/// displayed "#N" meaningful even though the whole-column position no longer is
102/// (a child may render above a lower-numbered top-level item).
103fn rank_ordinal(items: &[BacklogItem], target: &BacklogItem) -> usize {
104    items
105        .iter()
106        .filter(|it| {
107            it.parent == target.parent && it.status == target.status && it.rank <= target.rank
108        })
109        .count()
110}
111
112/// Load PBI `id` with backward links (children and dependents).
113///
114/// In addition to the forward `parent` and `depends_on` links, scan all items to find children and
115/// dependents. Return [`Error::NotInitialized`] for an uninitialized board or [`Error::NotFound`]
116/// when `id` does not exist.
117pub async fn item_detail(project_dir: &Path, id: &ItemId) -> Result<ItemDetail> {
118    item_detail_from_store(project_dir, id, false).await
119}
120
121/// Load archived PBI `id` with bidirectional links.
122pub async fn archived_item_detail(project_dir: &Path, id: &ItemId) -> Result<ItemDetail> {
123    item_detail_from_store(project_dir, id, true).await
124}
125
126async fn item_detail_from_store(
127    project_dir: &Path,
128    id: &ItemId,
129    archived: bool,
130) -> Result<ItemDetail> {
131    let (_board_dir, repo, config) = open_board(project_dir).await?;
132    let mut items = repo.list().await?; // Ascending rank order.
133    if archived {
134        items.extend(repo.list_archived().await?);
135        items.sort_by(BacklogItem::backlog_cmp);
136    }
137    super::apply_effective_points(
138        &mut items,
139        config.points.aggregate_children,
140        &crate::backlog::Status::new(&config.done_column),
141    );
142    let item = items
143        .iter()
144        .find(|it| &it.id == id)
145        .cloned()
146        .ok_or_else(|| Error::NotFound(id.clone()))?;
147
148    let children = items
149        .iter()
150        .filter(|it| it.parent.as_ref() == Some(id))
151        .map(|it| it.id.clone())
152        .collect();
153    let dependents = items
154        .iter()
155        .filter(|it| it.depends_on.contains(id))
156        .map(|it| it.id.clone())
157        .collect();
158    let rank_ordinal = rank_ordinal(&items, &item);
159
160    Ok(ItemDetail {
161        item,
162        rank_ordinal,
163        children,
164        dependents,
165    })
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::error::Error;
172    use crate::service::test_support::{init_temp, parent_edit};
173    use crate::service::{NewItem, add_item, edit_item};
174    use crate::storage::{BacklogItemRepository, FileRepository};
175
176    #[tokio::test]
177    async fn add_dependency_sets_and_persists_without_warning() {
178        let dir = init_temp().await;
179        let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
180        let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
181
182        let outcome = add_dependency(dir.path(), &a.id, &b.id)
183            .await
184            .expect("add dependency succeeds");
185        assert!(!outcome.cycle_warning, "unrelated dependency is acyclic");
186        assert_eq!(outcome.item.depends_on, vec![b.id.clone()]);
187
188        let repo = FileRepository::new(dir.path().join(".pinto"));
189        assert_eq!(repo.load(&a.id).await.unwrap().depends_on, vec![b.id]);
190    }
191
192    #[tokio::test]
193    async fn add_dependency_is_idempotent() {
194        let dir = init_temp().await;
195        let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
196        let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
197        add_dependency(dir.path(), &a.id, &b.id).await.unwrap();
198
199        let outcome = add_dependency(dir.path(), &a.id, &b.id).await.unwrap();
200        assert_eq!(outcome.item.depends_on, [b.id], "no duplicate dependency");
201    }
202
203    #[tokio::test]
204    async fn add_dependency_warns_on_cycle_but_still_records() {
205        let dir = init_temp().await;
206        let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
207        let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
208        // b depends on a. Here, adding "depends on b" to a creates a cycle (warning).
209        add_dependency(dir.path(), &b.id, &a.id).await.unwrap();
210
211        let outcome = add_dependency(dir.path(), &a.id, &b.id).await.unwrap();
212        assert!(outcome.cycle_warning, "back-edge should warn");
213        assert_eq!(outcome.item.depends_on, [b.id], "recorded despite warning");
214    }
215
216    #[tokio::test]
217    async fn add_dependency_missing_target_returns_not_found() {
218        let dir = init_temp().await;
219        let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
220        let err = add_dependency(dir.path(), &a.id, &ItemId::new("T", 99))
221            .await
222            .unwrap_err();
223        assert!(matches!(err, Error::NotFound(_)), "got {err:?}");
224    }
225
226    #[tokio::test]
227    async fn remove_dependency_drops_edge() {
228        let dir = init_temp().await;
229        let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
230        let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
231        add_dependency(dir.path(), &a.id, &b.id).await.unwrap();
232
233        let updated = remove_dependency(dir.path(), &a.id, &b.id)
234            .await
235            .expect("remove dependency succeeds");
236        assert!(updated.depends_on.is_empty());
237    }
238
239    #[tokio::test]
240    async fn remove_dependency_absent_edge_returns_not_found() {
241        let dir = init_temp().await;
242        let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
243        let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
244        let err = remove_dependency(dir.path(), &a.id, &b.id)
245            .await
246            .unwrap_err();
247        assert!(matches!(err, Error::NotFound(_)), "got {err:?}");
248    }
249
250    #[tokio::test]
251    async fn item_detail_reports_children_and_dependents() {
252        let dir = init_temp().await;
253        let epic = add_item(dir.path(), "Epic", NewItem::default())
254            .await
255            .unwrap();
256        let s1 = add_item(dir.path(), "Story 1", NewItem::default())
257            .await
258            .unwrap();
259        let s2 = add_item(dir.path(), "Story 2", NewItem::default())
260            .await
261            .unwrap();
262        let other = add_item(dir.path(), "Other", NewItem::default())
263            .await
264            .unwrap();
265        edit_item(dir.path(), &s1.id, parent_edit(Some(epic.id.clone())))
266            .await
267            .unwrap();
268        edit_item(dir.path(), &s2.id, parent_edit(Some(epic.id.clone())))
269            .await
270            .unwrap();
271        // other depends on epic.
272        add_dependency(dir.path(), &other.id, &epic.id)
273            .await
274            .unwrap();
275
276        let detail = item_detail(dir.path(), &epic.id)
277            .await
278            .expect("detail succeeds");
279        assert_eq!(detail.children, [s1.id, s2.id], "children in rank order");
280        assert_eq!(detail.dependents, [other.id], "reverse dependency edge");
281    }
282
283    #[tokio::test]
284    async fn archived_item_detail_reports_links_to_active_items() {
285        let dir = init_temp().await;
286        let parent = add_item(dir.path(), "Archived parent", NewItem::default())
287            .await
288            .unwrap();
289        let child = add_item(
290            dir.path(),
291            "Active child",
292            NewItem {
293                parent: Some(parent.id.clone()),
294                ..NewItem::default()
295            },
296        )
297        .await
298        .unwrap();
299        let dependent = add_item(dir.path(), "Active dependent", NewItem::default())
300            .await
301            .unwrap();
302        add_dependency(dir.path(), &dependent.id, &parent.id)
303            .await
304            .expect("dependency");
305        crate::service::remove_item(dir.path(), &parent.id, false)
306            .await
307            .expect("archive parent");
308
309        let detail = archived_item_detail(dir.path(), &parent.id)
310            .await
311            .expect("archived detail");
312        assert_eq!(detail.item, parent);
313        assert_eq!(detail.children, [child.id]);
314        assert_eq!(detail.dependents, [dependent.id]);
315    }
316
317    #[tokio::test]
318    async fn item_detail_missing_id_returns_not_found() {
319        let dir = init_temp().await;
320        let err = item_detail(dir.path(), &ItemId::new("T", 99))
321            .await
322            .unwrap_err();
323        assert!(matches!(err, Error::NotFound(_)), "got {err:?}");
324    }
325
326    // --- human-readable ordinal number of rank ---
327
328    #[test]
329    fn rank_ordinal_counts_position_within_same_status() {
330        use crate::backlog::Status;
331        use crate::rank::Rank;
332
333        let mk = |n: u32, status: &str, rank: Rank| {
334            BacklogItem::new(
335                ItemId::new("T", n),
336                "x",
337                Status::new(status),
338                rank,
339                chrono::DateTime::from_timestamp(0, 0).unwrap(),
340            )
341            .unwrap()
342        };
343        // rank In ascending order, a < b < c (todo), d is another column (done).
344        let a = mk(1, "todo", Rank::parse("a").unwrap());
345        let b = mk(2, "todo", Rank::parse("m").unwrap());
346        let c = mk(3, "todo", Rank::parse("z").unwrap());
347        let d = mk(4, "done", Rank::parse("a").unwrap());
348        let items = vec![a.clone(), b.clone(), c.clone(), d.clone()];
349
350        assert_eq!(rank_ordinal(&items, &a), 1);
351        assert_eq!(rank_ordinal(&items, &b), 2);
352        assert_eq!(rank_ordinal(&items, &c), 3);
353        // The beginning of another column (done) is the intra-column ordinal number 1 (unaffected by the number of items in other columns).
354        assert_eq!(rank_ordinal(&items, &d), 1);
355    }
356
357    #[test]
358    fn rank_ordinal_is_sibling_local_for_children() {
359        use crate::backlog::Status;
360        use crate::rank::Rank;
361
362        let mk = |n: u32, parent: Option<u32>, rank: Rank| {
363            let mut it = BacklogItem::new(
364                ItemId::new("T", n),
365                "x",
366                Status::new("todo"),
367                rank,
368                chrono::DateTime::from_timestamp(0, 0).unwrap(),
369            )
370            .unwrap();
371            it.parent = parent.map(|p| ItemId::new("T", p));
372            it
373        };
374        // Two top-level roots (T-1, T-4) and two children of T-1 (T-2, T-3).
375        let p = mk(1, None, Rank::parse("a").unwrap());
376        let r2 = mk(4, None, Rank::parse("b").unwrap());
377        let c1 = mk(2, Some(1), Rank::parse("m").unwrap());
378        let c2 = mk(3, Some(1), Rank::parse("z").unwrap());
379        let items = vec![p.clone(), r2.clone(), c1.clone(), c2.clone()];
380
381        // Roots are ranked among roots; children among their siblings — not the
382        // whole column, so the number reflects sibling order under the parent.
383        assert_eq!(rank_ordinal(&items, &p), 1, "first root");
384        assert_eq!(rank_ordinal(&items, &r2), 2, "second root");
385        assert_eq!(rank_ordinal(&items, &c1), 1, "first child of T-1");
386        assert_eq!(rank_ordinal(&items, &c2), 2, "second child of T-1");
387    }
388
389    #[tokio::test]
390    async fn item_detail_reports_within_column_rank_ordinal() {
391        let dir = init_temp().await;
392        let _a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
393        let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
394        let c = add_item(dir.path(), "C", NewItem::default()).await.unwrap();
395
396        // All todo (addition order = rank ascending order). C is third in column.
397        let detail = item_detail(dir.path(), &c.id).await.expect("detail");
398        assert_eq!(detail.rank_ordinal, 3);
399
400        // When B is moved to done, it becomes first in that column.
401        crate::service::move_item(dir.path(), &b.id, "done")
402            .await
403            .unwrap();
404        let detail = item_detail(dir.path(), &b.id).await.expect("detail");
405        assert_eq!(detail.rank_ordinal, 1, "first in the done column");
406    }
407}