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::{AcceptanceCriteriaProgress, 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::{DateTime, 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 assignee condition is an exact match. The stale condition matches
117/// `updated` timestamps at or before its cutoff. The roots-only condition uses the item's
118/// persisted parent link even when that parent is excluded by another filter.
119#[derive(Debug, Default, Clone)]
120pub struct ListFilter {
121    /// Include only PBIs whose persisted parent link is unset.
122    pub roots_only: bool,
123    /// Read archived PBIs instead of active PBIs.
124    pub archived: bool,
125    /// Exact workflow statuses to match. An empty list includes every status.
126    pub status: Vec<String>,
127    /// Exact assigned sprint ID to match.
128    pub sprint: Option<String>,
129    /// Exact assignee name to match.
130    pub assignee: Option<String>,
131    /// Labels to match. An empty list includes every label set.
132    pub labels: Vec<String>,
133    /// Matching mode for [`Self::labels`].
134    pub label_match: LabelMatch,
135    /// Search the item's fields and assigned sprint metadata.
136    pub search: Option<SearchFilter>,
137    /// Match PBIs whose `updated` timestamp is at or before this UTC cutoff.
138    pub stale_before: Option<DateTime<Utc>>,
139}
140
141impl ListFilter {
142    /// Does `item` match all conditions?
143    fn matches(&self, item: &BacklogItem, sprints: &[Sprint]) -> bool {
144        if self.roots_only && item.parent.is_some() {
145            return false;
146        }
147        if !self.status.is_empty()
148            && !self
149                .status
150                .iter()
151                .any(|status| item.status.as_str() == status)
152        {
153            return false;
154        }
155        if let Some(sprint) = &self.sprint
156            && item.sprint.as_deref() != Some(sprint.as_str())
157        {
158            return false;
159        }
160        if let Some(assignee) = &self.assignee
161            && item.assignee.as_deref() != Some(assignee.as_str())
162        {
163            return false;
164        }
165        if !self.labels.is_empty() && !self.label_match.matches(&item.labels, &self.labels) {
166            return false;
167        }
168        if let Some(cutoff) = self.stale_before
169            && item.updated > cutoff
170        {
171            return false;
172        }
173        if let Some(search) = &self.search {
174            let sprint = item
175                .sprint
176                .as_deref()
177                .and_then(|id| sprints.iter().find(|sprint| sprint.id.as_str() == id));
178            if !search.matches(item, sprint) {
179                return false;
180            }
181        }
182        true
183    }
184}
185
186/// Return PBIs from `project_dir` in canonical hierarchical priority order.
187///
188/// Items are grouped as a parent/child forest: roots in ascending rank order,
189/// each parent immediately followed by its subtree (siblings in rank order).
190/// A filtered-out or absent parent promotes its children to roots, so the tree
191/// is cut cleanly at the filter boundary. See [`crate::service::hierarchical_order`].
192///
193/// Apply only the conditions specified by `filter`. Return [`Error::NotInitialized`] for an
194/// uninitialized board. When `filter.roots_only` is set, items with a persisted parent link remain
195/// excluded even if that parent is outside the filtered result.
196pub async fn list_items(project_dir: &Path, filter: &ListFilter) -> Result<Vec<BacklogItem>> {
197    let (_board_dir, repo, config) = open_board(project_dir).await?;
198    for status in &filter.status {
199        if !config.columns.iter().any(|column| column == status) {
200            return Err(Error::UnknownStatus(status.clone()));
201        }
202    }
203    let mut items = if filter.archived {
204        repo.list_archived().await?
205    } else {
206        repo.list().await?
207    };
208    apply_effective_points(
209        &mut items,
210        config.points.aggregate_children,
211        &Status::new(&config.done_column),
212    );
213    let sprints = if filter.search.is_some() {
214        crate::storage::SprintRepository::list(&repo).await?
215    } else {
216        Vec::new()
217    };
218    // Filter first (preserving canonical backlog order), then flatten the
219    // surviving set into hierarchical priority order.
220    let filtered: Vec<BacklogItem> = items
221        .into_iter()
222        .filter(|item| filter.matches(item, &sprints))
223        .collect();
224    Ok(crate::service::hierarchical(filtered))
225}
226
227/// Load PBI `id` from the board in `project_dir`.
228///
229/// Return [`Error::NotInitialized`] for an uninitialized board or [`Error::NotFound`] when the ID
230/// does not exist.
231pub async fn show_item(project_dir: &Path, id: &ItemId) -> Result<BacklogItem> {
232    show_item_from_store(project_dir, id, false).await
233}
234
235/// Load archived PBI `id` from the board in `project_dir`.
236pub async fn show_archived_item(project_dir: &Path, id: &ItemId) -> Result<BacklogItem> {
237    show_item_from_store(project_dir, id, true).await
238}
239
240async fn show_item_from_store(
241    project_dir: &Path,
242    id: &ItemId,
243    archived: bool,
244) -> Result<BacklogItem> {
245    let (_board_dir, repo, config) = open_board(project_dir).await?;
246    let mut items = if archived {
247        repo.list_archived().await?
248    } else {
249        repo.list().await?
250    };
251    apply_effective_points(
252        &mut items,
253        config.points.aggregate_children,
254        &Status::new(&config.done_column),
255    );
256    items
257        .into_iter()
258        .find(|item| &item.id == id)
259        .ok_or_else(|| Error::NotFound(id.clone()))
260}
261
262/// Move PBI `id` to workflow status `to` and return the saved [`BacklogItem`].
263///
264/// Only statuses configured in `config.toml` are valid. Reject an unknown status with
265/// [`Error::UnknownStatus`] without changing the item. Return [`Error::NotInitialized`] for an
266/// uninitialized board or [`Error::NotFound`] when the ID does not exist.
267pub async fn move_item(project_dir: &Path, id: &ItemId, to: &str) -> Result<BacklogItem> {
268    Ok(move_item_with_outcome(project_dir, id, to).await?.item)
269}
270
271/// Result of moving a PBI, including the computed Acceptance Criteria progress.
272#[derive(Debug, Clone, PartialEq, Eq)]
273pub struct MoveOutcome {
274    /// The saved PBI after the transition.
275    pub item: BacklogItem,
276    /// Progress parsed from the item's unchanged Markdown body.
277    pub acceptance_criteria: AcceptanceCriteriaProgress,
278    /// Whether the requested destination is the configured completion column.
279    pub entered_done_column: bool,
280}
281
282/// Move PBI `id` and return the metadata needed by user interfaces for transition warnings.
283///
284/// The transition is persisted before the outcome is returned. Acceptance Criteria are computed
285/// from the existing body and are never written back to the item.
286pub async fn move_item_with_outcome(
287    project_dir: &Path,
288    id: &ItemId,
289    to: &str,
290) -> Result<MoveOutcome> {
291    let (_board_dir, repo, config, _lock) = open_board_locked(project_dir).await?;
292    let workflow = Workflow::new(config.columns.iter().map(Status::new));
293
294    let mut item = repo.load(id).await?;
295    item.transition_to(Status::new(to), &workflow, Utc::now())?;
296    let acceptance_criteria = AcceptanceCriteriaProgress::from_markdown(&item.body);
297    let entered_done_column = to == config.done_column;
298
299    repo.save(&item).await?;
300    repo.commit(&format!("pinto: update {}", item.id)).await?;
301    Ok(MoveOutcome {
302        item,
303        acceptance_criteria,
304        entered_done_column,
305    })
306}
307/// Result of [`remove_item`].
308#[derive(Debug, Clone, PartialEq, Eq)]
309pub enum RemoveOutcome {
310    /// Saved to `archive/` (destination path).
311    Archived(PathBuf),
312    /// Physically deleted.
313    Deleted,
314}
315
316/// Return active PBIs that would become dangling references if `target` were deleted.
317fn referencing_items(items: &[BacklogItem], target: &ItemId) -> String {
318    items
319        .iter()
320        .filter(|item| {
321            &item.id != target
322                && (item.parent.as_ref() == Some(target)
323                    || item
324                        .depends_on
325                        .iter()
326                        .any(|dependency| dependency == target))
327        })
328        .map(|item| item.id.to_string())
329        .collect::<Vec<_>>()
330        .join(", ")
331}
332
333/// Delete PBI with `id`. The default is archive backup, and if `permanent` is true, physical deletion is performed.
334///
335/// 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).
336/// `permanent` is a hard delete that cannot be undone and requires an explicit flag (`--force`) in the CLI.
337/// [`Error::NotInitialized`] if the board is uninitialized, [`Error::NotFound`] if the corresponding ID does not exist.
338pub async fn remove_item(
339    project_dir: &Path,
340    id: &ItemId,
341    permanent: bool,
342) -> Result<RemoveOutcome> {
343    let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
344    if permanent {
345        let items = repo.list().await?;
346        if items.iter().any(|item| &item.id == id) {
347            let references = referencing_items(&items, id);
348            if !references.is_empty() {
349                return Err(Error::ReferencedItem {
350                    item: id.clone(),
351                    references,
352                });
353            }
354        }
355        BacklogItemRepository::delete(&repo, id).await?;
356        repo.commit(&format!("pinto: remove {id}")).await?;
357        Ok(RemoveOutcome::Deleted)
358    } else {
359        let dest = repo.archive(id).await?;
360        repo.commit(&format!("pinto: archive {id}")).await?;
361        Ok(RemoveOutcome::Archived(dest))
362    }
363}
364
365/// Restore archived PBI `id` to the active backlog and return the unchanged item.
366pub async fn restore_item(project_dir: &Path, id: &ItemId) -> Result<BacklogItem> {
367    let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
368    repo.restore(id).await?;
369    repo.commit(&format!("pinto: restore {id}")).await?;
370    repo.load(id).await
371}