pinto/service/item/
edit.rs1use crate::backlog::{BacklogItem, ItemId};
4use crate::error::{Error, Result};
5use crate::service::relations::validate_parent;
6use crate::service::{open_board, open_board_locked, validate_sprint_assignment};
7use crate::storage::BacklogItemRepository;
8use chrono::Utc;
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Default, Clone)]
15pub struct ItemEdit {
16 pub title: Option<String>,
18 pub points: Option<u32>,
20 pub labels: Option<Vec<String>>,
22 pub assignee: Option<String>,
24 pub sprint: Option<String>,
26 pub body: Option<String>,
28 pub parent: Option<Option<ItemId>>,
33}
34
35impl ItemEdit {
36 fn is_empty(&self) -> bool {
38 self.title.is_none()
39 && self.points.is_none()
40 && self.labels.is_none()
41 && self.assignee.is_none()
42 && self.sprint.is_none()
43 && self.body.is_none()
44 && self.parent.is_none()
45 }
46}
47
48pub async fn edit_item(project_dir: &Path, id: &ItemId, edit: ItemEdit) -> Result<BacklogItem> {
56 let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
57
58 if edit.is_empty() {
59 return Err(Error::NothingToUpdate);
60 }
61 if let Some(title) = &edit.title
62 && title.trim().is_empty()
63 {
64 return Err(Error::EmptyTitle);
65 }
66 let validated_sprint = match edit.sprint.as_deref() {
67 Some(raw) => Some(validate_sprint_assignment(&repo, raw).await?),
68 None => None,
69 };
70
71 let mut item = repo.load(id).await?;
72
73 if let Some(Some(parent)) = &edit.parent {
76 let items = repo.list().await?;
77 validate_parent(&items, id, parent)?;
78 }
79
80 if let Some(title) = edit.title {
81 item.title = title;
82 }
83 if let Some(points) = edit.points {
84 item.points = Some(points);
85 }
86 if let Some(labels) = edit.labels {
87 item.labels = labels;
88 }
89 if let Some(assignee) = edit.assignee {
90 item.assignee = Some(assignee);
91 }
92 if let Some(sprint) = validated_sprint {
93 item.sprint = Some(sprint.to_string());
94 }
95 if let Some(body) = edit.body {
96 item.body = body;
97 }
98 if let Some(parent) = edit.parent {
99 item.parent = parent;
100 }
101 item.updated = Utc::now();
102
103 repo.save(&item).await?;
104 repo.commit(&format!("pinto: update {}", item.id)).await?;
105 Ok(item)
106}
107
108pub async fn item_edit_template(project_dir: &Path, id: &ItemId) -> Result<String> {
114 let (_board_dir, repo, _config) = open_board(project_dir).await?;
115 let item = repo.load(id).await?;
116 let markdown = crate::storage::item_to_markdown(&item)?;
117 Ok(with_edit_guidance(&markdown))
118}
119
120fn with_edit_guidance(markdown: &str) -> String {
127 let guide = crate::i18n::current().text(crate::i18n::Message::EditGuidance);
128 match markdown.split_once('\n') {
129 Some((first, rest)) if first == "+++" => format!("{first}\n{guide}\n{rest}"),
131 _ => markdown.to_string(),
132 }
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
137pub enum EditOutcome {
138 Updated(Box<BacklogItem>),
142 Unchanged,
144}
145
146pub async fn apply_item_edit(project_dir: &Path, id: &ItemId, edited: &str) -> Result<EditOutcome> {
157 let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
158 let stored = repo.load(id).await?;
159
160 let display_path = PathBuf::from(format!("{id}.md"));
162 let parsed = crate::storage::item_from_markdown(edited, &display_path).map_err(|e| {
163 Error::EditorInvalid {
164 message: e.to_string(),
165 }
166 })?;
167 let validated_sprint = match parsed.sprint.as_deref() {
168 Some(raw) => Some(
169 validate_sprint_assignment(&repo, raw)
170 .await
171 .map_err(|error| Error::EditorInvalid {
172 message: error.to_string(),
173 })?,
174 ),
175 None => None,
176 };
177
178 let mut updated = stored.clone();
180 updated.title = parsed.title;
181 updated.points = parsed.points;
182 updated.labels = parsed.labels;
183 updated.assignee = parsed.assignee;
184 updated.sprint = validated_sprint.map(|sprint| sprint.to_string());
185 updated.body = parsed.body;
186
187 if updated.parent != parsed.parent {
189 if let Some(parent) = &parsed.parent {
190 let items = repo.list().await?;
191 validate_parent(&items, id, parent)?;
192 }
193 updated.parent = parsed.parent;
194 }
195
196 if updated == stored {
198 return Ok(EditOutcome::Unchanged);
199 }
200 updated.updated = Utc::now();
201 repo.save(&updated).await?;
202 repo.commit(&format!("pinto: update {}", updated.id))
203 .await?;
204 Ok(EditOutcome::Updated(Box::new(updated)))
205}