Skip to main content

pinto/service/item/
crud.rs

1//! CRUD operations for backlog items: add, list, show, move, and remove.
2
3use crate::backlog::{BacklogItem, ItemId, Status, Workflow};
4use crate::error::{Error, Result};
5use crate::rank::Rank;
6use crate::service::relations::{validate_dependencies, validate_parent};
7use crate::service::{
8    LabelMatch, SearchFilter, apply_effective_points, open_board, open_board_locked,
9    validate_sprint_assignment,
10};
11use crate::sprint::Sprint;
12use crate::storage::BacklogItemRepository;
13use chrono::Utc;
14use std::path::{Path, PathBuf};
15
16/// Optional fields for [`add_item`]. Unspecified fields use their domain defaults.
17#[derive(Debug, Default, Clone)]
18pub struct NewItem {
19    /// Story points.
20    pub points: Option<u32>,
21    /// Labels assigned to the item.
22    pub labels: Vec<String>,
23    /// Sprint ID assigned to the item.
24    pub sprint: Option<String>,
25    /// Markdown body, including item-specific Acceptance Criteria.
26    pub body: String,
27    /// Parent PBI in the hierarchy.
28    pub parent: Option<ItemId>,
29    /// PBIs that must be completed before this item.
30    pub depends_on: Vec<ItemId>,
31}
32
33/// Result of adding a PBI, including warning-only dependency-cycle information.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct AddItemOutcome {
36    /// Newly persisted PBI.
37    pub item: BacklogItem,
38    /// Whether one of the requested dependencies creates a cycle.
39    pub cycle_warning: bool,
40}
41
42/// Add a PBI to the board in `project_dir` and return the saved [`BacklogItem`].
43///
44/// Prefix the ID with `config.project.key` and use the first workflow column as its status
45/// (normally `todo`). Assign the rank after the current backlog. Return [`Error::NotInitialized`]
46/// for an uninitialized board or [`Error::EmptyTitle`] when `title` is blank.
47pub async fn add_item(project_dir: &Path, title: &str, new: NewItem) -> Result<BacklogItem> {
48    Ok(add_item_with_outcome(project_dir, title, new).await?.item)
49}
50
51/// Add a PBI and report warning-only dependency cycles.
52pub async fn add_item_with_outcome(
53    project_dir: &Path,
54    title: &str,
55    new: NewItem,
56) -> Result<AddItemOutcome> {
57    let (board_dir, repo, config, _lock) = open_board_locked(project_dir).await?;
58    let config_path = board_dir.join("config.toml");
59    let status = config
60        .columns
61        .first()
62        .map(Status::new)
63        .ok_or_else(|| Error::parse(&config_path, "board config has no columns"))?;
64
65    let NewItem {
66        points,
67        labels,
68        sprint,
69        body,
70        parent,
71        depends_on,
72    } = new;
73    let sprint = match sprint {
74        Some(raw) => Some(validate_sprint_assignment(&repo, &raw).await?.to_string()),
75        None => None,
76    };
77
78    let id = repo.next_id(&config.project.key).await?;
79
80    // Rank is assigned to the end (all existing ranks are in ascending order, with the maximum number at the end).
81    let existing = repo.list().await?;
82    let rank = Rank::after(existing.last().map(|it| &it.rank));
83
84    let mut item = BacklogItem::new(id.clone(), title, status, rank, Utc::now())?;
85    if let Some(parent) = &parent {
86        validate_parent(&existing, &id, parent)?;
87    }
88    let cycle_warning = validate_dependencies(&existing, &id, &depends_on)?;
89
90    item.points = points;
91    item.labels = labels;
92    item.sprint = sprint;
93    item.body = body;
94    item.parent = parent;
95    item.depends_on = depends_on
96        .into_iter()
97        .fold(Vec::new(), |mut unique, dependency| {
98            if !unique.contains(&dependency) {
99                unique.push(dependency);
100            }
101            unique
102        });
103
104    repo.save(&item).await?;
105    repo.commit(&format!("pinto: add {}", item.id)).await?;
106    Ok(AddItemOutcome {
107        item,
108        cycle_warning,
109    })
110}
111
112/// Filters for [`list_items`]. Empty fields do not filter their corresponding property.
113///
114/// Multiple status specifications use OR (a PBI matches any selected status); label specifications
115/// use [`LabelMatch::Any`] by default and can use [`LabelMatch::All`]; roots-only and other filters
116/// are combined with AND. The roots-only condition uses the item's persisted parent link even
117/// when that parent is excluded by another filter.
118#[derive(Debug, Default, Clone)]
119pub struct ListFilter {
120    /// Include only PBIs whose persisted parent link is unset.
121    pub roots_only: bool,
122    /// Exact workflow statuses to match. An empty list includes every status.
123    pub status: Vec<String>,
124    /// Exact assigned sprint ID to match.
125    pub sprint: Option<String>,
126    /// Labels to match. An empty list includes every label set.
127    pub labels: Vec<String>,
128    /// Matching mode for [`Self::labels`].
129    pub label_match: LabelMatch,
130    /// Search the item's fields and assigned sprint metadata.
131    pub search: Option<SearchFilter>,
132}
133
134impl ListFilter {
135    /// Does `item` match all conditions?
136    fn matches(&self, item: &BacklogItem, sprints: &[Sprint]) -> bool {
137        if self.roots_only && item.parent.is_some() {
138            return false;
139        }
140        if !self.status.is_empty()
141            && !self
142                .status
143                .iter()
144                .any(|status| item.status.as_str() == status)
145        {
146            return false;
147        }
148        if let Some(sprint) = &self.sprint
149            && item.sprint.as_deref() != Some(sprint.as_str())
150        {
151            return false;
152        }
153        if !self.labels.is_empty() && !self.label_match.matches(&item.labels, &self.labels) {
154            return false;
155        }
156        if let Some(search) = &self.search {
157            let sprint = item
158                .sprint
159                .as_deref()
160                .and_then(|id| sprints.iter().find(|sprint| sprint.id.as_str() == id));
161            if !search.matches(item, sprint) {
162                return false;
163            }
164        }
165        true
166    }
167}
168
169/// Return PBIs from `project_dir` in canonical hierarchical priority order.
170///
171/// Items are grouped as a parent/child forest: roots in ascending rank order,
172/// each parent immediately followed by its subtree (siblings in rank order).
173/// A filtered-out or absent parent promotes its children to roots, so the tree
174/// is cut cleanly at the filter boundary. See [`crate::service::hierarchical_order`].
175///
176/// Apply only the conditions specified by `filter`. Return [`Error::NotInitialized`] for an
177/// uninitialized board. When `filter.roots_only` is set, items with a persisted parent link remain
178/// excluded even if that parent is outside the filtered result.
179pub async fn list_items(project_dir: &Path, filter: &ListFilter) -> Result<Vec<BacklogItem>> {
180    let (_board_dir, repo, config) = open_board(project_dir).await?;
181    for status in &filter.status {
182        if !config.columns.iter().any(|column| column == status) {
183            return Err(Error::UnknownStatus(status.clone()));
184        }
185    }
186    let mut items = repo.list().await?;
187    apply_effective_points(
188        &mut items,
189        config.points.aggregate_children,
190        &Status::new(&config.done_column),
191    );
192    let sprints = if filter.search.is_some() {
193        crate::storage::SprintRepository::list(&repo).await?
194    } else {
195        Vec::new()
196    };
197    // Filter first (preserving canonical backlog order), then flatten the
198    // surviving set into hierarchical priority order.
199    let filtered: Vec<BacklogItem> = items
200        .into_iter()
201        .filter(|item| filter.matches(item, &sprints))
202        .collect();
203    Ok(crate::service::hierarchical(filtered))
204}
205
206/// Load PBI `id` from the board in `project_dir`.
207///
208/// Return [`Error::NotInitialized`] for an uninitialized board or [`Error::NotFound`] when the ID
209/// does not exist.
210pub async fn show_item(project_dir: &Path, id: &ItemId) -> Result<BacklogItem> {
211    let (_board_dir, repo, config) = open_board(project_dir).await?;
212    let mut items = repo.list().await?;
213    apply_effective_points(
214        &mut items,
215        config.points.aggregate_children,
216        &Status::new(&config.done_column),
217    );
218    items
219        .into_iter()
220        .find(|item| &item.id == id)
221        .ok_or_else(|| Error::NotFound(id.clone()))
222}
223
224/// Move PBI `id` to workflow status `to` and return the saved [`BacklogItem`].
225///
226/// Only statuses configured in `config.toml` are valid. Reject an unknown status with
227/// [`Error::UnknownStatus`] without changing the item. Return [`Error::NotInitialized`] for an
228/// uninitialized board or [`Error::NotFound`] when the ID does not exist.
229pub async fn move_item(project_dir: &Path, id: &ItemId, to: &str) -> Result<BacklogItem> {
230    let (_board_dir, repo, config, _lock) = open_board_locked(project_dir).await?;
231    let workflow = Workflow::new(config.columns.iter().map(Status::new));
232
233    let mut item = repo.load(id).await?;
234    item.transition_to(Status::new(to), &workflow, Utc::now())?;
235
236    repo.save(&item).await?;
237    repo.commit(&format!("pinto: update {}", item.id)).await?;
238    Ok(item)
239}
240/// Result of [`remove_item`].
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub enum RemoveOutcome {
243    /// Saved to `archive/` (destination path).
244    Archived(PathBuf),
245    /// Physically deleted.
246    Deleted,
247}
248
249/// Return active PBIs that would become dangling references if `target` were deleted.
250fn referencing_items(items: &[BacklogItem], target: &ItemId) -> String {
251    items
252        .iter()
253        .filter(|item| {
254            &item.id != target
255                && (item.parent.as_ref() == Some(target)
256                    || item
257                        .depends_on
258                        .iter()
259                        .any(|dependency| dependency == target))
260        })
261        .map(|item| item.id.to_string())
262        .collect::<Vec<_>>()
263        .join(", ")
264}
265
266/// Delete PBI with `id`. The default is archive backup, and if `permanent` is true, physical deletion is performed.
267///
268/// A non-destructive operation that moves the archive to `.pinto/archive/<id>.md` (it can be tracked with Git and can be restored from the backup location).
269/// `permanent` is a hard delete that cannot be undone and requires an explicit flag (`--force`) in the CLI.
270/// [`Error::NotInitialized`] if the board is uninitialized, [`Error::NotFound`] if the corresponding ID does not exist.
271pub async fn remove_item(
272    project_dir: &Path,
273    id: &ItemId,
274    permanent: bool,
275) -> Result<RemoveOutcome> {
276    let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
277    if permanent {
278        let items = repo.list().await?;
279        if items.iter().any(|item| &item.id == id) {
280            let references = referencing_items(&items, id);
281            if !references.is_empty() {
282                return Err(Error::ReferencedItem {
283                    item: id.clone(),
284                    references,
285                });
286            }
287        }
288        BacklogItemRepository::delete(&repo, id).await?;
289        repo.commit(&format!("pinto: remove {id}")).await?;
290        Ok(RemoveOutcome::Deleted)
291    } else {
292        let dest = repo.archive(id).await?;
293        repo.commit(&format!("pinto: archive {id}")).await?;
294        Ok(RemoveOutcome::Archived(dest))
295    }
296}