Skip to main content

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}