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 let (_board_dir, repo, config) = open_board(project_dir).await?;
119 let mut items = repo.list().await?; 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 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 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 #[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 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 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 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 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 let detail = item_detail(dir.path(), &c.id).await.expect("detail");
347 assert_eq!(detail.rank_ordinal, 3);
348
349 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}