pinto/service/item/split.rs
1//! Split an existing PBI into one or more new PBIs.
2//!
3//! Splitting keeps the source item and derives fresh PBIs from it, optionally wiring a
4//! relationship (parent-child or dependency) between the source and each new item. The new bodies
5//! copy the source by default, but the caller may request an empty body or supply explicit text
6//! (a template is resolved to explicit text before reaching this layer).
7
8use crate::backlog::{BacklogItem, ItemId, Status};
9use crate::error::{Error, Result};
10use crate::rank::Rank;
11use crate::service::open_board_locked;
12use crate::storage::BacklogItemRepository;
13use chrono::Utc;
14use std::path::Path;
15
16/// How each new PBI relates to the source item.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum SplitRelationship {
19 /// The new PBIs are independent of the source.
20 #[default]
21 None,
22 /// The source becomes the parent of each new PBI.
23 Child,
24 /// The source depends on each new PBI (each new PBI must be completed first).
25 Dependency,
26}
27
28/// Body assigned to every new PBI produced by a split.
29#[derive(Debug, Clone, PartialEq, Eq, Default)]
30pub enum SplitBody {
31 /// Copy the source item's body.
32 #[default]
33 Copy,
34 /// Start each new PBI with an empty body.
35 Empty,
36 /// Use the supplied text (a resolved template is passed as explicit text).
37 Explicit(String),
38}
39
40/// Instructions for [`split_item`].
41#[derive(Debug, Clone, Default)]
42pub struct SplitSpec {
43 /// Titles of the new PBIs to create, in order. Must contain at least one non-blank title.
44 pub titles: Vec<String>,
45 /// Relationship wired between the source and each new PBI.
46 pub relationship: SplitRelationship,
47 /// Body assigned to each new PBI.
48 pub body: SplitBody,
49}
50
51/// Result of [`split_item`].
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct SplitOutcome {
54 /// Source PBI the new items were derived from, reflecting any relationship update.
55 pub source: BacklogItem,
56 /// Newly persisted PBIs, in the order their titles were supplied.
57 pub created: Vec<BacklogItem>,
58}
59
60/// Split the PBI `source` into the new PBIs described by `spec`.
61///
62/// Each new PBI is appended to the backlog with the first workflow column as its status, exactly as
63/// [`crate::service::add_item`] does. When `spec.relationship` is [`SplitRelationship::Child`] the
64/// source becomes the parent of every new PBI; when it is [`SplitRelationship::Dependency`] the
65/// source gains a dependency on every new PBI. Splitting cannot introduce a cycle because the new
66/// items carry no other edges, so no cycle warning is produced.
67///
68/// Return [`Error::NotInitialized`] for an uninitialized board, [`Error::NotFound`] when `source`
69/// is absent, or [`Error::EmptyTitle`] when `spec.titles` is empty or contains a blank title.
70pub async fn split_item(
71 project_dir: &Path,
72 source: &ItemId,
73 spec: SplitSpec,
74) -> Result<SplitOutcome> {
75 let SplitSpec {
76 titles,
77 relationship,
78 body,
79 } = spec;
80 // Reject an empty request or any blank title before taking the board lock so a bad request
81 // fails fast and a partial split never persists.
82 if titles.is_empty() || titles.iter().any(|title| title.trim().is_empty()) {
83 return Err(Error::EmptyTitle);
84 }
85
86 let (board_dir, repo, config, _lock) = open_board_locked(project_dir).await?;
87 let config_path = board_dir.join("config.toml");
88 let status = config
89 .columns
90 .first()
91 .map(Status::new)
92 .ok_or_else(|| Error::parse(&config_path, "board config has no columns"))?;
93
94 let existing = repo.list().await?;
95 let mut source_item = existing
96 .iter()
97 .find(|item| &item.id == source)
98 .cloned()
99 .ok_or_else(|| Error::NotFound(source.clone()))?;
100
101 let new_body = match &body {
102 SplitBody::Copy => source_item.body.clone(),
103 SplitBody::Empty => String::new(),
104 SplitBody::Explicit(text) => text.clone(),
105 };
106
107 // Ranks are appended after the current backlog maximum, advancing as each new item is minted.
108 // Each new item is saved before the next ID is requested so `next_id` observes it and stays
109 // strictly increasing, mirroring how `add_item` persists a single item.
110 let mut last_rank = existing.last().map(|item| item.rank.clone());
111 let mut created = Vec::with_capacity(titles.len());
112 for title in &titles {
113 let id = repo.next_id(&config.project.key).await?;
114 let rank = Rank::after(last_rank.as_ref());
115 last_rank = Some(rank.clone());
116 let mut item = BacklogItem::new(id, title, status.clone(), rank, Utc::now())?;
117 item.body = new_body.clone();
118 if relationship == SplitRelationship::Child {
119 item.parent = Some(source.clone());
120 }
121 repo.save(&item).await?;
122 created.push(item);
123 }
124
125 if relationship == SplitRelationship::Dependency {
126 for item in &created {
127 if !source_item.depends_on.contains(&item.id) {
128 source_item.depends_on.push(item.id.clone());
129 }
130 }
131 source_item.updated = Utc::now();
132 repo.save(&source_item).await?;
133 }
134
135 let ids = created
136 .iter()
137 .map(|item| item.id.to_string())
138 .collect::<Vec<_>>()
139 .join(", ");
140 repo.commit(&format!("pinto: split {source} into {ids}"))
141 .await?;
142
143 Ok(SplitOutcome {
144 source: source_item,
145 created,
146 })
147}