1use 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#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct DependencyOutcome {
14 pub item: BacklogItem,
16 pub cycle_warning: bool,
19}
20
21pub 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 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
53pub 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#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct ItemDetail {
78 pub item: BacklogItem,
80 pub rank_ordinal: usize,
88 pub children: Vec<ItemId>,
90 pub dependents: Vec<ItemId>,
92}
93
94fn 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
112pub async fn item_detail(project_dir: &Path, id: &ItemId) -> Result<ItemDetail> {
118 item_detail_from_store(project_dir, id, false).await
119}
120
121pub 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?; 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 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 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 #[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 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 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 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 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 let detail = item_detail(dir.path(), &c.id).await.expect("detail");
398 assert_eq!(detail.rank_ordinal, 3);
399
400 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}