Skip to main content

pinto/backlog/
item.rs

1//! Product Backlog Item.
2
3use super::{ItemId, Status, Workflow};
4use crate::error::{Error, Result};
5use crate::rank::Rank;
6use chrono::{DateTime, Utc};
7
8/// Product Backlog Item (PBI).
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct BacklogItem {
11    pub id: ItemId,
12    pub title: String,
13    pub status: Status,
14    /// Backlog sort order; lexicographically smaller ranks have higher priority.
15    pub rank: Rank,
16    pub points: Option<u32>,
17    pub labels: Vec<String>,
18    pub assignee: Option<String>,
19    pub sprint: Option<String>,
20    /// Parent PBI. Parent-child links are kept acyclic, forming a tree hierarchy.
21    ///
22    /// This represents a hierarchy such as epic → story → task; the model does not assign a type
23    /// such as epic or story. Cycles are rejected by [`crate::backlog::parent_creates_cycle`].
24    pub parent: Option<ItemId>,
25    /// PBIs that this item depends on and that should be completed first.
26    ///
27    /// Dependencies are independent of the parent-child hierarchy. Cycles are allowed but
28    /// undesirable; callers can detect them with [`crate::backlog::dependency_creates_cycle`].
29    pub depends_on: Vec<ItemId>,
30    /// Time when work first started, recorded when the item leaves the first workflow column.
31    ///
32    /// Record it once on the first transition out of the first column and retain it when the item
33    /// is reopened. It is `None` when work has not started.
34    pub start_at: Option<DateTime<Utc>>,
35    /// Time when the item most recently entered the completion column.
36    ///
37    /// Update it on every entry to the last column and clear it when the item leaves that column.
38    /// It is `None` when the item is not complete.
39    pub done_at: Option<DateTime<Utc>>,
40    /// Git commit SHAs associated with this PBI, in insertion order without duplicates.
41    ///
42    /// These are plain-text links and do not require Git to be installed. `link` stores the supplied
43    /// string; `scan` discovers SHAs from commit messages containing the item ID.
44    pub commits: Vec<String>,
45    pub created: DateTime<Utc>,
46    pub updated: DateTime<Utc>,
47    pub body: String,
48}
49
50impl BacklogItem {
51    /// Create a minimally configured PBI with default points, labels, and relationships.
52    ///
53    /// The caller supplies the backlog [`Rank`]. Return [`Error::EmptyTitle`] if `title` is empty.
54    pub fn new(
55        id: ItemId,
56        title: impl Into<String>,
57        status: Status,
58        rank: Rank,
59        now: DateTime<Utc>,
60    ) -> Result<Self> {
61        let title = title.into();
62        if title.trim().is_empty() {
63            return Err(Error::EmptyTitle);
64        }
65        Ok(Self {
66            id,
67            title,
68            status,
69            rank,
70            points: None,
71            labels: Vec::new(),
72            assignee: None,
73            sprint: None,
74            parent: None,
75            depends_on: Vec::new(),
76            start_at: None,
77            done_at: None,
78            created: now,
79            updated: now,
80            body: String::new(),
81            commits: Vec::new(),
82        })
83    }
84
85    /// Canonical backlog order shared by every view (`list`, `board`, `kanban`).
86    ///
87    /// Ascending [`rank`](Self::rank) first, then a deterministic
88    /// `(prefix, number)` tie-break so items with equal ranks never reorder
89    /// between views (a stable sort on this comparator preserves that order).
90    /// This is the single source of truth for "default" ordering; column-level
91    /// overrides (e.g. the terminal column's `done_at` sort) layer on top of it.
92    #[must_use]
93    pub fn backlog_cmp(&self, other: &Self) -> std::cmp::Ordering {
94        self.rank.cmp(&other.rank).then_with(|| {
95            (self.id.prefix(), self.id.number()).cmp(&(other.id.prefix(), other.id.number()))
96        })
97    }
98
99    /// Add related commit `sha`; return `true` when it was added.
100    ///
101    /// Trim leading and trailing whitespace, ignore blank values, reject duplicates, and preserve
102    /// insertion order so `git diff` remains stable.
103    pub fn link_commit(&mut self, sha: impl Into<String>) -> bool {
104        let sha = sha.into();
105        let sha = sha.trim();
106        if sha.is_empty() || self.commits.iter().any(|c| c == sha) {
107            return false;
108        }
109        self.commits.push(sha.to_string());
110        true
111    }
112
113    /// Remove related commit `sha` (`true` if removed, `false` if not).
114    pub fn unlink_commit(&mut self, sha: &str) -> bool {
115        let before = self.commits.len();
116        self.commits.retain(|c| c != sha);
117        self.commits.len() != before
118    }
119
120    /// Transition the state to `to` and update `updated` and working time (`start_at` / `done_at`).
121    ///
122    /// If `to` is not in the workflow, return [`Error::UnknownStatus`] without changing the item.
123    ///
124    /// Determine work timestamps from column position rather than column names, because workflows
125    /// can be renamed:
126    /// - On the first transition out of the first column, record `now` in `start_at`. Entering the
127    ///   last column directly also starts the work, including in a single-column workflow.
128    /// - On every entry to the last column, record `now` in `done_at`; clear it when leaving that
129    ///   column.
130    pub fn transition_to(
131        &mut self,
132        to: Status,
133        workflow: &Workflow,
134        now: DateTime<Utc>,
135    ) -> Result<()> {
136        if !workflow.contains(&to) {
137            return Err(Error::UnknownStatus(to.as_str().to_string()));
138        }
139        let is_first = workflow.default_status() == Some(&to);
140        let is_last = workflow.columns().last() == Some(&to);
141        // Record the first transition out of the first column only once. In a single-column
142        // workflow, entering the only (and completed) column also counts as starting work.
143        if (!is_first || is_last) && self.start_at.is_none() {
144            self.start_at = Some(now);
145        }
146        // Record completion time on entry to the last column and clear it when leaving.
147        self.done_at = is_last.then_some(now);
148        self.status = to;
149        self.updated = now;
150        Ok(())
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    fn epoch() -> DateTime<Utc> {
159        DateTime::from_timestamp(0, 0).expect("valid epoch")
160    }
161
162    fn workflow() -> Workflow {
163        Workflow::new(
164            ["todo", "in-progress", "review", "done"]
165                .into_iter()
166                .map(Status::new),
167        )
168    }
169
170    fn rank() -> Rank {
171        Rank::between(None, None).expect("open bounds produce a rank")
172    }
173
174    #[test]
175    fn new_item_uses_defaults() {
176        let item = BacklogItem::new(
177            ItemId::new("T", 1),
178            "Write model",
179            Status::new("todo"),
180            rank(),
181            epoch(),
182        )
183        .unwrap();
184        assert_eq!(item.title, "Write model");
185        assert_eq!(item.status, Status::new("todo"));
186        assert_eq!(item.rank, rank());
187        assert!(item.labels.is_empty());
188        assert_eq!(item.points, None);
189        assert_eq!(item.created, epoch());
190        assert_eq!(item.updated, epoch());
191    }
192
193    #[test]
194    fn new_item_rejects_empty_title() {
195        let err = BacklogItem::new(
196            ItemId::new("T", 1),
197            "   ",
198            Status::new("todo"),
199            rank(),
200            epoch(),
201        )
202        .unwrap_err();
203        assert_eq!(err, Error::EmptyTitle);
204    }
205
206    #[test]
207    fn transition_updates_status_and_timestamp() {
208        let mut item = BacklogItem::new(
209            ItemId::new("T", 1),
210            "Task",
211            Status::new("todo"),
212            rank(),
213            epoch(),
214        )
215        .unwrap();
216        let later = epoch() + chrono::Duration::seconds(60);
217
218        item.transition_to(Status::new("in-progress"), &workflow(), later)
219            .unwrap();
220
221        assert_eq!(item.status, Status::new("in-progress"));
222        assert_eq!(item.updated, later);
223        assert_eq!(item.created, epoch(), "created must not change");
224    }
225
226    #[test]
227    fn transition_to_unknown_status_is_rejected_and_leaves_item_unchanged() {
228        let mut item = BacklogItem::new(
229            ItemId::new("T", 1),
230            "Task",
231            Status::new("todo"),
232            rank(),
233            epoch(),
234        )
235        .unwrap();
236        let later = epoch() + chrono::Duration::seconds(60);
237
238        let err = item
239            .transition_to(Status::new("archived"), &workflow(), later)
240            .unwrap_err();
241
242        assert_eq!(err, Error::UnknownStatus("archived".to_string()));
243        assert_eq!(item.status, Status::new("todo"), "status must be unchanged");
244        assert_eq!(
245            item.updated,
246            epoch(),
247            "updated must be unchanged on failure"
248        );
249    }
250
251    fn todo_item() -> BacklogItem {
252        BacklogItem::new(
253            ItemId::new("T", 1),
254            "Task",
255            Status::new("todo"),
256            rank(),
257            epoch(),
258        )
259        .unwrap()
260    }
261
262    #[test]
263    fn new_item_has_no_work_timestamps() {
264        let item = todo_item();
265        assert_eq!(item.start_at, None);
266        assert_eq!(item.done_at, None);
267    }
268
269    #[test]
270    fn new_item_has_no_commits() {
271        assert!(todo_item().commits.is_empty());
272    }
273
274    #[test]
275    fn link_commit_appends_and_deduplicates() {
276        let mut item = todo_item();
277        assert!(item.link_commit("abc123"), "first link is added");
278        assert!(!item.link_commit("abc123"), "duplicate is ignored");
279        assert!(item.link_commit("def456"), "distinct sha is added");
280        assert_eq!(item.commits, ["abc123", "def456"], "insertion order kept");
281    }
282
283    #[test]
284    fn link_commit_trims_and_ignores_blank() {
285        let mut item = todo_item();
286        assert!(item.link_commit("  abc123  "), "surrounding space trimmed");
287        assert!(!item.link_commit("   "), "blank sha ignored");
288        assert!(!item.link_commit("abc123"), "trimmed value deduplicates");
289        assert_eq!(item.commits, ["abc123"]);
290    }
291
292    #[test]
293    fn unlink_commit_removes_when_present() {
294        let mut item = todo_item();
295        item.link_commit("abc123");
296        item.link_commit("def456");
297        assert!(item.unlink_commit("abc123"), "present sha removed");
298        assert!(!item.unlink_commit("abc123"), "already gone returns false");
299        assert_eq!(item.commits, ["def456"]);
300    }
301
302    #[test]
303    fn entering_a_working_column_records_start_at() {
304        let mut item = todo_item();
305        let t = epoch() + chrono::Duration::seconds(60);
306
307        item.transition_to(Status::new("in-progress"), &workflow(), t)
308            .unwrap();
309
310        assert_eq!(item.start_at, Some(t), "start_at recorded on first work");
311        assert_eq!(item.done_at, None, "not done yet");
312    }
313
314    #[test]
315    fn start_at_is_kept_on_reentry_not_reset() {
316        let mut item = todo_item();
317        let first = epoch() + chrono::Duration::seconds(60);
318        let later = epoch() + chrono::Duration::seconds(120);
319
320        item.transition_to(Status::new("in-progress"), &workflow(), first)
321            .unwrap();
322        // Even if you return to todo and start again, the initial start_at will be maintained.
323        item.transition_to(Status::new("todo"), &workflow(), later)
324            .unwrap();
325        item.transition_to(Status::new("review"), &workflow(), later)
326            .unwrap();
327
328        assert_eq!(item.start_at, Some(first), "keeps the first start time");
329    }
330
331    #[test]
332    fn entering_the_terminal_column_records_done_at() {
333        let mut item = todo_item();
334        let t = epoch() + chrono::Duration::seconds(60);
335
336        item.transition_to(Status::new("done"), &workflow(), t)
337            .unwrap();
338
339        assert_eq!(item.done_at, Some(t), "done_at recorded when completed");
340        assert_eq!(
341            item.start_at,
342            Some(t),
343            "direct todo→done also marks work started"
344        );
345    }
346
347    #[test]
348    fn leaving_the_terminal_column_clears_done_at() {
349        let mut item = todo_item();
350        let done_at = epoch() + chrono::Duration::seconds(60);
351        let reopened = epoch() + chrono::Duration::seconds(120);
352
353        item.transition_to(Status::new("done"), &workflow(), done_at)
354            .unwrap();
355        assert_eq!(item.done_at, Some(done_at));
356
357        item.transition_to(Status::new("in-progress"), &workflow(), reopened)
358            .unwrap();
359
360        assert_eq!(item.done_at, None, "reopening clears completion time");
361        assert_eq!(item.start_at, Some(done_at), "start time is preserved");
362    }
363
364    #[test]
365    fn single_column_workflow_records_both_start_and_done() {
366        // Even in a single-column workflow where the first column = the last column, completion implies that the work has started.
367        // Record both start_at / done_at (consistent with multi-column todo→done).
368        let single = Workflow::new(std::iter::once(Status::new("done")));
369        let mut item = BacklogItem::new(
370            ItemId::new("T", 1),
371            "Task",
372            Status::new("done"),
373            rank(),
374            epoch(),
375        )
376        .unwrap();
377        let t = epoch() + chrono::Duration::seconds(60);
378
379        item.transition_to(Status::new("done"), &single, t).unwrap();
380
381        assert_eq!(item.done_at, Some(t), "completion recorded");
382        assert_eq!(
383            item.start_at,
384            Some(t),
385            "single-column done also marks start"
386        );
387    }
388
389    #[test]
390    fn backlog_cmp_orders_by_rank_then_id() {
391        // Canonical backlog order shared by list / board / kanban: ascending rank
392        // first, then a deterministic (prefix, number) tie-break so equal ranks
393        // never reorder between views.
394        let make = |prefix: &str, n: u32, r: &str| {
395            BacklogItem::new(
396                ItemId::new(prefix, n),
397                "x",
398                Status::new("todo"),
399                Rank::parse(r).expect("valid rank"),
400                epoch(),
401            )
402            .unwrap()
403        };
404        // Lower rank sorts first regardless of id.
405        let low = make("T", 9, "g");
406        let high = make("T", 1, "m");
407        assert_eq!(low.backlog_cmp(&high), std::cmp::Ordering::Less);
408        assert_eq!(high.backlog_cmp(&low), std::cmp::Ordering::Greater);
409
410        // Equal rank: break the tie by (prefix, number).
411        let a = make("A", 2, "g");
412        let b = make("B", 1, "g");
413        assert_eq!(
414            a.backlog_cmp(&b),
415            std::cmp::Ordering::Less,
416            "prefix breaks tie"
417        );
418        let t2 = make("T", 2, "g");
419        let t10 = make("T", 10, "g");
420        assert_eq!(
421            t2.backlog_cmp(&t10),
422            std::cmp::Ordering::Less,
423            "numeric (not lexical) number order within a prefix"
424        );
425    }
426
427    #[test]
428    fn re_entering_the_terminal_column_refreshes_done_at() {
429        let mut item = todo_item();
430        let first_done = epoch() + chrono::Duration::seconds(60);
431        let reopened = epoch() + chrono::Duration::seconds(120);
432        let second_done = epoch() + chrono::Duration::seconds(180);
433
434        item.transition_to(Status::new("done"), &workflow(), first_done)
435            .unwrap();
436        item.transition_to(Status::new("review"), &workflow(), reopened)
437            .unwrap();
438        item.transition_to(Status::new("done"), &workflow(), second_done)
439            .unwrap();
440
441        assert_eq!(item.done_at, Some(second_done), "latest completion wins");
442    }
443}