1use 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#[derive(Debug, Default, Clone)]
18pub struct NewItem {
19 pub points: Option<u32>,
21 pub labels: Vec<String>,
23 pub sprint: Option<String>,
25 pub body: String,
27 pub parent: Option<ItemId>,
29 pub depends_on: Vec<ItemId>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct AddItemOutcome {
36 pub item: BacklogItem,
38 pub cycle_warning: bool,
40}
41
42pub 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
51pub 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 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#[derive(Debug, Default, Clone)]
119pub struct ListFilter {
120 pub roots_only: bool,
122 pub status: Vec<String>,
124 pub sprint: Option<String>,
126 pub labels: Vec<String>,
128 pub label_match: LabelMatch,
130 pub search: Option<SearchFilter>,
132}
133
134impl ListFilter {
135 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
169pub 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 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
206pub 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
224pub 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#[derive(Debug, Clone, PartialEq, Eq)]
242pub enum RemoveOutcome {
243 Archived(PathBuf),
245 Deleted,
247}
248
249fn 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
266pub 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}