1use super::{ItemId, Status, Workflow};
4use crate::error::{Error, Result};
5use crate::rank::Rank;
6use chrono::{DateTime, Utc};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct BacklogItem {
11 pub id: ItemId,
12 pub title: String,
13 pub status: Status,
14 pub rank: Rank,
16 pub points: Option<u32>,
17 pub labels: Vec<String>,
18 pub assignee: Option<String>,
19 pub sprint: Option<String>,
20 pub parent: Option<ItemId>,
25 pub depends_on: Vec<ItemId>,
30 pub start_at: Option<DateTime<Utc>>,
35 pub done_at: Option<DateTime<Utc>>,
40 pub commits: Vec<String>,
45 pub created: DateTime<Utc>,
46 pub updated: DateTime<Utc>,
47 pub body: String,
48}
49
50impl BacklogItem {
51 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 #[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 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 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 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 if (!is_first || is_last) && self.start_at.is_none() {
144 self.start_at = Some(now);
145 }
146 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 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 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 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 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 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}