Skip to main content

pinto/service/item/
edit.rs

1//! Editing a backlog item: field edits and the `$EDITOR` round-trip.
2
3use 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/// Fields to update with [`edit_item`]. A `None` field remains unchanged.
12///
13/// `labels` replaces the entire existing set of labels if you pass `Some`.
14#[derive(Debug, Default, Clone)]
15pub struct ItemEdit {
16    /// New title; a blank value returns [`Error::EmptyTitle`].
17    pub title: Option<String>,
18    /// Story points.
19    pub points: Option<u32>,
20    /// Replacement label set when `Some`.
21    pub labels: Option<Vec<String>>,
22    /// Assignee.
23    pub assignee: Option<String>,
24    /// Sprint ID assignment.
25    pub sprint: Option<String>,
26    /// Markdown body, including Acceptance Criteria.
27    pub body: Option<String>,
28    /// Parent change: `None` leaves it unchanged, `Some(None)` clears it, and `Some(Some(id))`
29    /// assigns the parent.
30    ///
31    /// Parent-child links remain acyclic; a cycle returns [`Error::ParentCycle`].
32    pub parent: Option<Option<ItemId>>,
33}
34
35impl ItemEdit {
36    /// Are there no fields specified to update?
37    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
48/// Update the specified fields of PBI `id`, refresh `updated`, and return the saved [`BacklogItem`].
49///
50/// Fields set to `None` remain unchanged. Validate every field before saving, so an error leaves
51/// the on-disk item untouched. Return [`Error::NothingToUpdate`] when no field is supplied,
52/// [`Error::EmptyTitle`] for a blank title, [`Error::ParentCycle`] for a cyclic parent, and
53/// [`Error::NotFound`] when the item or proposed parent does not exist. Return
54/// [`Error::NotInitialized`] for an uninitialized board.
55pub 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    // Parent validation needs the complete graph; perform it before changing any fields so the
74    // update remains atomic.
75    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
108/// Format PBI `id` as the Markdown template used for `$EDITOR` editing.
109///
110/// Use the normal `+++` frontmatter format and insert TOML-comment guidance for editable fields.
111/// This read-only operation does not acquire the board lock. Return [`Error::NotInitialized`] for
112/// an uninitialized board or [`Error::NotFound`] when `id` does not exist.
113pub 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
120/// Insert editing guidance (as TOML comments) right after the frontmatter delimiter.
121///
122/// The guidance is localized through the i18n layer so the selected locale is honored (the
123/// hard-coded text used to leak Japanese regardless of `$LANG`). TOML ignores `#` comment
124/// lines, so the guidance round-trips harmlessly: `id` / `status` / `rank` / `depends_on` and
125/// the timestamps are owned by pinto and cannot be changed here.
126fn with_edit_guidance(markdown: &str) -> String {
127    let guide = crate::i18n::current().text(crate::i18n::Message::EditGuidance);
128    match markdown.split_once('\n') {
129        // The frontmatter always opens with `+++`; insert the guidance immediately after it.
130        Some((first, rest)) if first == "+++" => format!("{first}\n{guide}\n{rest}"),
131        _ => markdown.to_string(),
132    }
133}
134
135/// Result of [`apply_item_edit`].
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub enum EditOutcome {
138    /// The edited contents were reflected and saved (updated PBI).
139    ///
140    /// Wrap it in `Box` to avoid size difference between variants (`Unchanged` has no data).
141    Updated(Box<BacklogItem>),
142    /// There was no change in the content and it was not saved.
143    Unchanged,
144}
145
146/// Validate Markdown edited with `$EDITOR`, apply permitted fields to PBI `id`, and save it.
147///
148/// Only `title`, `points`, `labels`, `assignee`, `sprint`, `parent`, and the body are editable.
149/// pinto retains `id`, `status`, `rank`, `depends_on`, and timestamps; use `move`, `reorder`, and
150/// `dep` to change the managed fields.
151///
152/// Validate everything before saving, so invalid edits do not change on-disk state. Syntax errors
153/// and empty titles return [`Error::EditorInvalid`]; a missing parent returns [`Error::NotFound`];
154/// a cyclic parent returns [`Error::ParentCycle`]. If no editable field changes, return
155/// [`EditOutcome::Unchanged`] without updating the timestamp.
156pub 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    // Parse the edited text and convert syntax or validation failures into user-correctable errors.
161    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    // Copy only editable fields; retain all pinto-managed values from the stored item.
179    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    // Validate a changed parent against the full graph; skip the scan when the parent is unchanged.
188    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 all editable fields match, avoid writing and leave `updated` unchanged.
197    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}