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    let (_board_dir, repo, config) = open_board(project_dir).await?;
119    let mut items = repo.list().await?; // Ascending rank order.
120    super::apply_effective_points(
121        &mut items,
122        config.points.aggregate_children,
123        &crate::backlog::Status::new(&config.done_column),
124    );
125    let item = items
126        .iter()
127        .find(|it| &it.id == id)
128        .cloned()
129        .ok_or_else(|| Error::NotFound(id.clone()))?;
130
131    let children = items
132        .iter()
133        .filter(|it| it.parent.as_ref() == Some(id))
134        .map(|it| it.id.clone())
135        .collect();
136    let dependents = items
137        .iter()
138        .filter(|it| it.depends_on.contains(id))
139        .map(|it| it.id.clone())
140        .collect();
141    let rank_ordinal = rank_ordinal(&items, &item);
142
143    Ok(ItemDetail {
144        item,
145        rank_ordinal,
146        children,
147        dependents,
148    })
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::error::Error;
155    use crate::service::test_support::{init_temp, parent_edit};
156    use crate::service::{NewItem, add_item, edit_item};
157    use crate::storage::{BacklogItemRepository, FileRepository};
158
159    #[tokio::test]
160    async fn add_dependency_sets_and_persists_without_warning() {
161        let dir = init_temp().await;
162        let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
163        let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
164
165        let outcome = add_dependency(dir.path(), &a.id, &b.id)
166            .await
167            .expect("add dependency succeeds");
168        assert!(!outcome.cycle_warning, "unrelated dependency is acyclic");
169        assert_eq!(outcome.item.depends_on, vec![b.id.clone()]);
170
171        let repo = FileRepository::new(dir.path().join(".pinto"));
172        assert_eq!(repo.load(&a.id).await.unwrap().depends_on, vec![b.id]);
173    }
174
175    #[tokio::test]
176    async fn add_dependency_is_idempotent() {
177        let dir = init_temp().await;
178        let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
179        let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
180        add_dependency(dir.path(), &a.id, &b.id).await.unwrap();
181
182        let outcome = add_dependency(dir.path(), &a.id, &b.id).await.unwrap();
183        assert_eq!(outcome.item.depends_on, [b.id], "no duplicate dependency");
184    }
185
186    #[tokio::test]
187    async fn add_dependency_warns_on_cycle_but_still_records() {
188        let dir = init_temp().await;
189        let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
190        let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
191        // b depends on a. Here, adding "depends on b" to a creates a cycle (warning).
192        add_dependency(dir.path(), &b.id, &a.id).await.unwrap();
193
194        let outcome = add_dependency(dir.path(), &a.id, &b.id).await.unwrap();
195        assert!(outcome.cycle_warning, "back-edge should warn");
196        assert_eq!(outcome.item.depends_on, [b.id], "recorded despite warning");
197    }
198
199    #[tokio::test]
200    async fn add_dependency_missing_target_returns_not_found() {
201        let dir = init_temp().await;
202        let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
203        let err = add_dependency(dir.path(), &a.id, &ItemId::new("T", 99))
204            .await
205            .unwrap_err();
206        assert!(matches!(err, Error::NotFound(_)), "got {err:?}");
207    }
208
209    #[tokio::test]
210    async fn remove_dependency_drops_edge() {
211        let dir = init_temp().await;
212        let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
213        let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
214        add_dependency(dir.path(), &a.id, &b.id).await.unwrap();
215
216        let updated = remove_dependency(dir.path(), &a.id, &b.id)
217            .await
218            .expect("remove dependency succeeds");
219        assert!(updated.depends_on.is_empty());
220    }
221
222    #[tokio::test]
223    async fn remove_dependency_absent_edge_returns_not_found() {
224        let dir = init_temp().await;
225        let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
226        let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
227        let err = remove_dependency(dir.path(), &a.id, &b.id)
228            .await
229            .unwrap_err();
230        assert!(matches!(err, Error::NotFound(_)), "got {err:?}");
231    }
232
233    #[tokio::test]
234    async fn item_detail_reports_children_and_dependents() {
235        let dir = init_temp().await;
236        let epic = add_item(dir.path(), "Epic", NewItem::default())
237            .await
238            .unwrap();
239        let s1 = add_item(dir.path(), "Story 1", NewItem::default())
240            .await
241            .unwrap();
242        let s2 = add_item(dir.path(), "Story 2", NewItem::default())
243            .await
244            .unwrap();
245        let other = add_item(dir.path(), "Other", NewItem::default())
246            .await
247            .unwrap();
248        edit_item(dir.path(), &s1.id, parent_edit(Some(epic.id.clone())))
249            .await
250            .unwrap();
251        edit_item(dir.path(), &s2.id, parent_edit(Some(epic.id.clone())))
252            .await
253            .unwrap();
254        // other depends on epic.
255        add_dependency(dir.path(), &other.id, &epic.id)
256            .await
257            .unwrap();
258
259        let detail = item_detail(dir.path(), &epic.id)
260            .await
261            .expect("detail succeeds");
262        assert_eq!(detail.children, [s1.id, s2.id], "children in rank order");
263        assert_eq!(detail.dependents, [other.id], "reverse dependency edge");
264    }
265
266    #[tokio::test]
267    async fn item_detail_missing_id_returns_not_found() {
268        let dir = init_temp().await;
269        let err = item_detail(dir.path(), &ItemId::new("T", 99))
270            .await
271            .unwrap_err();
272        assert!(matches!(err, Error::NotFound(_)), "got {err:?}");
273    }
274
275    // --- human-readable ordinal number of rank ---
276
277    #[test]
278    fn rank_ordinal_counts_position_within_same_status() {
279        use crate::backlog::Status;
280        use crate::rank::Rank;
281
282        let mk = |n: u32, status: &str, rank: Rank| {
283            BacklogItem::new(
284                ItemId::new("T", n),
285                "x",
286                Status::new(status),
287                rank,
288                chrono::DateTime::from_timestamp(0, 0).unwrap(),
289            )
290            .unwrap()
291        };
292        // rank In ascending order, a < b < c (todo), d is another column (done).
293        let a = mk(1, "todo", Rank::parse("a").unwrap());
294        let b = mk(2, "todo", Rank::parse("m").unwrap());
295        let c = mk(3, "todo", Rank::parse("z").unwrap());
296        let d = mk(4, "done", Rank::parse("a").unwrap());
297        let items = vec![a.clone(), b.clone(), c.clone(), d.clone()];
298
299        assert_eq!(rank_ordinal(&items, &a), 1);
300        assert_eq!(rank_ordinal(&items, &b), 2);
301        assert_eq!(rank_ordinal(&items, &c), 3);
302        // The beginning of another column (done) is the intra-column ordinal number 1 (unaffected by the number of items in other columns).
303        assert_eq!(rank_ordinal(&items, &d), 1);
304    }
305
306    #[test]
307    fn rank_ordinal_is_sibling_local_for_children() {
308        use crate::backlog::Status;
309        use crate::rank::Rank;
310
311        let mk = |n: u32, parent: Option<u32>, rank: Rank| {
312            let mut it = BacklogItem::new(
313                ItemId::new("T", n),
314                "x",
315                Status::new("todo"),
316                rank,
317                chrono::DateTime::from_timestamp(0, 0).unwrap(),
318            )
319            .unwrap();
320            it.parent = parent.map(|p| ItemId::new("T", p));
321            it
322        };
323        // Two top-level roots (T-1, T-4) and two children of T-1 (T-2, T-3).
324        let p = mk(1, None, Rank::parse("a").unwrap());
325        let r2 = mk(4, None, Rank::parse("b").unwrap());
326        let c1 = mk(2, Some(1), Rank::parse("m").unwrap());
327        let c2 = mk(3, Some(1), Rank::parse("z").unwrap());
328        let items = vec![p.clone(), r2.clone(), c1.clone(), c2.clone()];
329
330        // Roots are ranked among roots; children among their siblings — not the
331        // whole column, so the number reflects sibling order under the parent.
332        assert_eq!(rank_ordinal(&items, &p), 1, "first root");
333        assert_eq!(rank_ordinal(&items, &r2), 2, "second root");
334        assert_eq!(rank_ordinal(&items, &c1), 1, "first child of T-1");
335        assert_eq!(rank_ordinal(&items, &c2), 2, "second child of T-1");
336    }
337
338    #[tokio::test]
339    async fn item_detail_reports_within_column_rank_ordinal() {
340        let dir = init_temp().await;
341        let _a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
342        let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
343        let c = add_item(dir.path(), "C", NewItem::default()).await.unwrap();
344
345        // All todo (addition order = rank ascending order). C is third in column.
346        let detail = item_detail(dir.path(), &c.id).await.expect("detail");
347        assert_eq!(detail.rank_ordinal, 3);
348
349        // When B is moved to done, it becomes first in that column.
350        crate::service::move_item(dir.path(), &b.id, "done")
351            .await
352            .unwrap();
353        let detail = item_detail(dir.path(), &b.id).await.expect("detail");
354        assert_eq!(detail.rank_ordinal, 1, "first in the done column");
355    }
356}