1use super::{open_board, open_board_locked};
8use crate::backlog::{BacklogItem, ItemId};
9use crate::error::{Error, Result};
10use crate::sprint::{Sprint, SprintCapacity, SprintId, SprintSpillover, SprintState};
11use crate::storage::{Backend, BacklogItemRepository, SprintRepository};
12use chrono::{DateTime, Utc};
13use rayon::prelude::*;
14use std::path::Path;
15
16const VELOCITY_WARNING_RECENT: usize = 5;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum SprintLoadWarningKind {
21 Capacity,
23 Velocity,
25}
26
27impl SprintLoadWarningKind {
28 #[must_use]
30 pub const fn as_str(self) -> &'static str {
31 match self {
32 Self::Capacity => "capacity",
33 Self::Velocity => "velocity",
34 }
35 }
36
37 #[must_use]
39 pub const fn unit(self) -> &'static str {
40 match self {
41 Self::Capacity => "hours",
42 Self::Velocity => "points",
43 }
44 }
45}
46
47#[derive(Debug, Clone, PartialEq)]
49pub struct SprintLoadWarning {
50 pub kind: SprintLoadWarningKind,
52 pub points: u32,
54 pub threshold: f64,
56}
57
58pub async fn sprint_load_warnings(
60 project_dir: &Path,
61 id: &SprintId,
62) -> Result<Vec<SprintLoadWarning>> {
63 let (_board_dir, repo, _config) = open_board(project_dir).await?;
64 let (sprints, items) = tokio::try_join!(
65 SprintRepository::list(&repo),
66 BacklogItemRepository::list(&repo)
67 )?;
68 let target = sprints
69 .iter()
70 .find(|sprint| sprint.id == *id)
71 .ok_or_else(|| Error::SprintNotFound(id.clone()))?;
72 Ok(sprint_load_warnings_for(target, &sprints, &items))
73}
74
75fn sprint_load_warnings_for(
77 target: &Sprint,
78 sprints: &[Sprint],
79 items: &[BacklogItem],
80) -> Vec<SprintLoadWarning> {
81 let assigned_points = items
82 .iter()
83 .filter(|item| item.sprint.as_deref() == Some(target.id.as_str()))
84 .filter_map(|item| item.points)
85 .fold(0_u32, u32::saturating_add);
86 let mut warnings = Vec::with_capacity(2);
87
88 if let Some(capacity) = target.capacity()
89 && f64::from(assigned_points) > capacity.hours
90 {
91 warnings.push(SprintLoadWarning {
92 kind: SprintLoadWarningKind::Capacity,
93 points: assigned_points,
94 threshold: capacity.hours,
95 });
96 }
97
98 if let Some(threshold) = historical_velocity_threshold(target, sprints, items)
99 && f64::from(assigned_points) > threshold
100 {
101 warnings.push(SprintLoadWarning {
102 kind: SprintLoadWarningKind::Velocity,
103 points: assigned_points,
104 threshold,
105 });
106 }
107
108 warnings
109}
110
111fn historical_velocity_threshold(
113 target: &Sprint,
114 sprints: &[Sprint],
115 items: &[BacklogItem],
116) -> Option<f64> {
117 let target_index = sprints.iter().position(|sprint| sprint.id == target.id)?;
118 let history: Vec<Sprint> = sprints[..target_index]
119 .iter()
120 .filter(|sprint| sprint.state == SprintState::Closed)
121 .cloned()
122 .collect();
123 if history.is_empty() {
124 return None;
125 }
126 Some(super::velocity::compute_velocity(&history, items, VELOCITY_WARNING_RECENT).average_points)
127}
128
129pub async fn create_sprint(
138 project_dir: &Path,
139 id: &SprintId,
140 title: &str,
141 goal: Option<String>,
142 period: Option<(DateTime<Utc>, DateTime<Utc>)>,
143) -> Result<Sprint> {
144 if let Some((start, end)) = period
146 && start > end
147 {
148 return Err(Error::InvalidSprintPeriod {
149 start: start.date_naive(),
150 end: end.date_naive(),
151 });
152 }
153
154 let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
155
156 match SprintRepository::load(&repo, id).await {
158 Ok(_) => return Err(Error::SprintExists(id.clone())),
159 Err(Error::SprintNotFound(_)) => {}
160 Err(e) => return Err(e),
161 }
162
163 let mut sprint = Sprint::new(id.clone(), title, Utc::now())?;
164 if let Some(goal) = goal {
165 sprint.goal = goal;
166 }
167 if let Some((start, end)) = period {
168 sprint.start = Some(start);
169 sprint.end = Some(end);
170 }
171 SprintRepository::save(&repo, &sprint).await?;
172 repo.commit(&format!("pinto: add {}", sprint.id)).await?;
173 Ok(sprint)
174}
175
176pub async fn edit_sprint(
182 project_dir: &Path,
183 id: &SprintId,
184 title: Option<String>,
185 goal: Option<String>,
186 period: Option<(DateTime<Utc>, DateTime<Utc>)>,
187) -> Result<Sprint> {
188 let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
189 let mut sprint = SprintRepository::load(&repo, id).await?;
190 sprint.update_details(title, goal, period, Utc::now())?;
191 SprintRepository::save(&repo, &sprint).await?;
192 repo.commit(&format!("pinto: update {}", sprint.id)).await?;
193 Ok(sprint)
194}
195
196pub async fn delete_sprint(project_dir: &Path, id: &SprintId) -> Result<()> {
201 let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
202 SprintRepository::load(&repo, id).await?;
203
204 let now = Utc::now();
205 let assigned = BacklogItemRepository::list(&repo)
206 .await?
207 .into_iter()
208 .filter(|item| item.sprint.as_deref() == Some(id.as_str()))
209 .collect::<Vec<_>>();
210 for mut item in assigned {
211 item.sprint = None;
212 item.updated = now;
213 BacklogItemRepository::save(&repo, &item).await?;
214 }
215
216 SprintRepository::delete(&repo, id).await?;
217 repo.commit(&format!("pinto: delete {id}")).await?;
218 Ok(())
219}
220
221pub async fn start_sprint(project_dir: &Path, id: &SprintId) -> Result<Sprint> {
227 transition_sprint(project_dir, id, Sprint::start).await
228}
229
230#[derive(Debug, Default, Clone, PartialEq, Eq)]
232pub enum SprintCloseAction {
233 #[default]
235 Retain,
236 Rollover(SprintId),
238 Release,
240}
241
242pub async fn close_sprint(
249 project_dir: &Path,
250 id: &SprintId,
251 action: SprintCloseAction,
252) -> Result<Sprint> {
253 let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
254 let original_sprint = SprintRepository::load(&repo, id).await?;
255 if original_sprint.state != SprintState::Active {
256 return Err(Error::InvalidSprintTransition {
257 from: original_sprint.state,
258 to: SprintState::Closed,
259 });
260 }
261
262 if let SprintCloseAction::Rollover(target) = &action {
263 if target == id {
264 return Err(Error::InvalidFilterOption(
265 "a sprint cannot roll unfinished PBIs over to itself".to_string(),
266 ));
267 }
268 validate_sprint_assignment(&repo, target.as_str()).await?;
269 }
270
271 let original_items = BacklogItemRepository::list(&repo)
272 .await?
273 .into_par_iter()
274 .filter(|item| item.sprint.as_deref() == Some(id.as_str()) && item.done_at.is_none())
275 .collect::<Vec<_>>();
276 let spillover = original_items
277 .par_iter()
278 .map(|item| SprintSpillover {
279 points: item.points.unwrap_or(0),
280 items: 1,
281 unestimated_items: u32::from(item.points.is_none()),
282 })
283 .reduce(SprintSpillover::default, |left, right| SprintSpillover {
284 points: left.points.saturating_add(right.points),
285 items: left.items.saturating_add(right.items),
286 unestimated_items: left
287 .unestimated_items
288 .saturating_add(right.unestimated_items),
289 });
290
291 let now = Utc::now();
292 let mut sprint = original_sprint.clone();
293 sprint.close(now, spillover)?;
294 let mut updated_items = if action == SprintCloseAction::Retain {
295 Vec::new()
296 } else {
297 original_items.clone()
298 };
299 for item in &mut updated_items {
300 match &action {
301 SprintCloseAction::Retain => {}
302 SprintCloseAction::Rollover(target) => item.sprint = Some(target.to_string()),
303 SprintCloseAction::Release => item.sprint = None,
304 }
305 item.updated = now;
306 }
307
308 for (index, item) in updated_items.iter().enumerate() {
309 if let Err(error) = BacklogItemRepository::save(&repo, item).await {
310 rollback_sprint_close(&repo, &original_sprint, &original_items[..=index], &error)
311 .await?;
312 return Err(error);
313 }
314 }
315 if let Err(error) = SprintRepository::save(&repo, &sprint).await {
316 rollback_sprint_close(&repo, &original_sprint, &original_items, &error).await?;
317 return Err(error);
318 }
319 repo.commit(&format!("pinto: update {}", sprint.id)).await?;
322 Ok(sprint)
323}
324
325async fn transition_sprint(
330 project_dir: &Path,
331 id: &SprintId,
332 transition: impl FnOnce(&mut Sprint, chrono::DateTime<Utc>) -> Result<()>,
333) -> Result<Sprint> {
334 let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
335 let mut sprint = SprintRepository::load(&repo, id).await?;
336 transition(&mut sprint, Utc::now())?;
337 SprintRepository::save(&repo, &sprint).await?;
338 repo.commit(&format!("pinto: update {}", sprint.id)).await?;
339 Ok(sprint)
340}
341
342async fn rollback_sprint_close(
344 repo: &Backend,
345 original_sprint: &Sprint,
346 original_items: &[BacklogItem],
347 operation_error: &Error,
348) -> Result<()> {
349 for item in original_items.iter().rev() {
350 if let Err(rollback_error) = BacklogItemRepository::save(repo, item).await {
351 return Err(Error::task(format!(
352 "{operation_error}; failed to roll back sprint close: {rollback_error}"
353 )));
354 }
355 }
356 if let Err(rollback_error) = SprintRepository::save(repo, original_sprint).await {
357 return Err(Error::task(format!(
358 "{operation_error}; failed to roll back sprint close: {rollback_error}"
359 )));
360 }
361 Ok(())
362}
363
364pub(crate) async fn validate_sprint_assignment(repo: &Backend, raw: &str) -> Result<SprintId> {
366 let id = SprintId::new(raw)?;
367 let sprint = SprintRepository::load(repo, &id).await?;
368 if sprint.state == SprintState::Closed {
369 return Err(Error::SprintClosed(id));
370 }
371 Ok(id)
372}
373
374pub async fn assign_sprint(
382 project_dir: &Path,
383 sprint_id: &SprintId,
384 item_id: &ItemId,
385) -> Result<BacklogItem> {
386 assign_sprint_raw(project_dir, sprint_id.as_str(), item_id).await
387}
388
389pub async fn assign_sprint_raw(
391 project_dir: &Path,
392 raw_sprint_id: &str,
393 item_id: &ItemId,
394) -> Result<BacklogItem> {
395 let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
396 let sprint_id = validate_sprint_assignment(&repo, raw_sprint_id).await?;
397 let mut item = BacklogItemRepository::load(&repo, item_id).await?;
398 item.sprint = Some(sprint_id.to_string());
399 item.updated = Utc::now();
400 BacklogItemRepository::save(&repo, &item).await?;
401 repo.commit(&format!("pinto: update {}", item.id)).await?;
402 Ok(item)
403}
404
405pub async fn assign_sprint_by_status(
413 project_dir: &Path,
414 sprint_id: &SprintId,
415 status: &str,
416 limit: Option<usize>,
417) -> Result<Vec<BacklogItem>> {
418 let (_board_dir, repo, config, _lock) = open_board_locked(project_dir).await?;
419
420 if !config.columns.iter().any(|column| column == status) {
421 return Err(Error::UnknownStatus(status.to_string()));
422 }
423 if limit == Some(0) {
424 return Err(Error::InvalidFilterOption(
425 "--limit must be at least 1".to_string(),
426 ));
427 }
428 validate_sprint_assignment(&repo, sprint_id.as_str()).await?;
429
430 let mut candidates = BacklogItemRepository::list(&repo)
433 .await?
434 .into_iter()
435 .filter(|item| item.status.as_str() == status)
436 .filter(|item| item.sprint.as_deref() != Some(sprint_id.as_str()))
437 .collect::<Vec<_>>();
438 if let Some(limit) = limit {
439 candidates.truncate(limit);
440 }
441
442 if let Some(item) = candidates.iter().find(|item| item.sprint.is_some())
445 && let Some(assigned_sprint) = item.sprint.as_deref()
446 {
447 return Err(Error::InvalidFilterOption(format!(
448 "{} is already assigned to sprint {}; remove it before bulk assignment",
449 item.id, assigned_sprint
450 )));
451 }
452
453 let original = candidates.clone();
454 let now = Utc::now();
455 let mut assigned = Vec::with_capacity(candidates.len());
456 for (index, mut item) in candidates.into_iter().enumerate() {
457 item.sprint = Some(sprint_id.to_string());
458 item.updated = now;
459 if let Err(error) = BacklogItemRepository::save(&repo, &item).await {
460 rollback_bulk_assignment(&repo, &original[..=index], &error).await?;
461 return Err(error);
462 }
463 assigned.push(item);
464 }
465 if !assigned.is_empty()
466 && let Err(error) = repo
467 .commit(&format!(
468 "pinto: assign {} item(s) to {}",
469 assigned.len(),
470 sprint_id
471 ))
472 .await
473 {
474 rollback_bulk_assignment(&repo, &original, &error).await?;
475 return Err(error);
476 }
477 Ok(assigned)
478}
479
480async fn rollback_bulk_assignment(
482 repo: &Backend,
483 original: &[BacklogItem],
484 operation_error: &Error,
485) -> Result<()> {
486 for item in original.iter().rev() {
487 if let Err(rollback_error) = BacklogItemRepository::save(repo, item).await {
488 return Err(Error::InvalidFilterOption(format!(
489 "{operation_error}; failed to roll back bulk assignment: {rollback_error}"
490 )));
491 }
492 }
493 Ok(())
494}
495
496pub async fn unassign_sprint(
502 project_dir: &Path,
503 sprint_id: &SprintId,
504 item_id: &ItemId,
505) -> Result<BacklogItem> {
506 let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
507 let mut item = BacklogItemRepository::load(&repo, item_id).await?;
508 if item.sprint.as_deref() != Some(sprint_id.as_str()) {
509 return Err(Error::NotInSprint {
510 item: item_id.clone(),
511 sprint: sprint_id.clone(),
512 });
513 }
514 item.sprint = None;
515 item.updated = Utc::now();
516 BacklogItemRepository::save(&repo, &item).await?;
517 repo.commit(&format!("pinto: update {}", item.id)).await?;
518 Ok(item)
519}
520
521pub async fn list_sprints(project_dir: &Path) -> Result<Vec<Sprint>> {
525 let (_board_dir, repo, _config) = open_board(project_dir).await?;
526 SprintRepository::list(&repo).await
527}
528
529pub async fn set_sprint_capacity(
531 project_dir: &Path,
532 id: &SprintId,
533 daily_work_hours: f64,
534 holiday_days: u32,
535 deduction_factor: f64,
536) -> Result<SprintCapacity> {
537 let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
538 let mut sprint = SprintRepository::load(&repo, id).await?;
539 sprint.set_capacity(daily_work_hours, holiday_days, deduction_factor)?;
540 sprint.updated = Utc::now();
541 let capacity = sprint
542 .capacity()
543 .ok_or_else(|| Error::SprintCapacityUnset(id.clone()))?;
544 SprintRepository::save(&repo, &sprint).await?;
545 repo.commit(&format!("pinto: update {}", sprint.id)).await?;
546 Ok(capacity)
547}
548
549pub async fn sprint_capacity(project_dir: &Path, id: &SprintId) -> Result<SprintCapacity> {
551 let (_board_dir, repo, _config) = open_board(project_dir).await?;
552 let sprint = SprintRepository::load(&repo, id).await?;
553 sprint
554 .capacity()
555 .ok_or_else(|| Error::SprintCapacityUnset(id.clone()))
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561 use crate::service::test_support::init_temp;
562 use crate::service::{NewItem, add_item, move_item};
563 use crate::sprint::SprintState;
564 use crate::storage::{Backend, FileRepository};
565
566 fn sid(s: &str) -> SprintId {
567 SprintId::new(s).expect("valid sprint id")
568 }
569
570 #[tokio::test]
571 async fn sprint_assignment_validation_checks_grammar_and_existence() {
572 let dir = init_temp().await;
573 create_sprint(dir.path(), &sid("S-1"), "Sprint 1", None, None)
574 .await
575 .unwrap();
576 create_sprint(
577 dir.path(),
578 &sid("S-2"),
579 "Sprint 2",
580 Some("Ship the sprint".to_string()),
581 None,
582 )
583 .await
584 .unwrap();
585 start_sprint(dir.path(), &sid("S-2")).await.unwrap();
586 close_sprint(dir.path(), &sid("S-2"), SprintCloseAction::Retain)
587 .await
588 .unwrap();
589 let repo = Backend::File(FileRepository::new(dir.path().join(".pinto")));
590
591 assert_eq!(
592 validate_sprint_assignment(&repo, "S 1").await.unwrap_err(),
593 Error::InvalidSprintId("S 1".to_string())
594 );
595 assert_eq!(
596 validate_sprint_assignment(&repo, "S-9").await.unwrap_err(),
597 Error::SprintNotFound(sid("S-9"))
598 );
599 assert_eq!(
600 validate_sprint_assignment(&repo, "S-1")
601 .await
602 .expect("existing sprint validates"),
603 sid("S-1")
604 );
605 assert_eq!(
606 validate_sprint_assignment(&repo, "S-2").await.unwrap_err(),
607 Error::SprintClosed(sid("S-2"))
608 );
609 }
610
611 fn date(y: i32, m: u32, d: u32) -> DateTime<Utc> {
613 chrono::NaiveDate::from_ymd_opt(y, m, d)
614 .expect("valid date")
615 .and_hms_opt(0, 0, 0)
616 .expect("valid time")
617 .and_utc()
618 }
619
620 #[tokio::test]
621 async fn create_persists_planned_sprint() {
622 let dir = init_temp().await;
623
624 let sprint = create_sprint(dir.path(), &sid("S-1"), "Sprint 1", None, None)
625 .await
626 .expect("create succeeds");
627 assert_eq!(sprint.title, "Sprint 1");
628 assert_eq!(sprint.state, SprintState::Planned);
629
630 let repo = FileRepository::new(dir.path().join(".pinto"));
632 let loaded = SprintRepository::load(&repo, &sid("S-1")).await.unwrap();
633 assert_eq!(loaded, sprint);
634 }
635
636 #[tokio::test]
637 async fn create_stores_goal_as_body() {
638 let dir = init_temp().await;
639 let sprint = create_sprint(
640 dir.path(),
641 &sid("S-1"),
642 "Sprint 1",
643 Some("Ship the MVP".to_string()),
644 None,
645 )
646 .await
647 .unwrap();
648 assert_eq!(sprint.goal, "Ship the MVP");
649 }
650
651 #[tokio::test]
652 async fn create_rejects_duplicate_id() {
653 let dir = init_temp().await;
654 create_sprint(dir.path(), &sid("S-1"), "First", None, None)
655 .await
656 .unwrap();
657
658 let err = create_sprint(dir.path(), &sid("S-1"), "Second", None, None)
659 .await
660 .unwrap_err();
661 assert_eq!(err, Error::SprintExists(sid("S-1")));
662
663 let repo = FileRepository::new(dir.path().join(".pinto"));
665 assert_eq!(
666 SprintRepository::load(&repo, &sid("S-1"))
667 .await
668 .unwrap()
669 .title,
670 "First"
671 );
672 }
673
674 #[tokio::test]
675 async fn create_rejects_empty_title() {
676 let dir = init_temp().await;
677 let err = create_sprint(dir.path(), &sid("S-1"), " ", None, None)
678 .await
679 .unwrap_err();
680 assert_eq!(err, Error::EmptySprintTitle);
681 }
682
683 #[tokio::test]
684 async fn create_stores_planned_period() {
685 let dir = init_temp().await;
686 let start = date(2026, 7, 6);
687 let end = date(2026, 7, 20);
688 let sprint = create_sprint(
689 dir.path(),
690 &sid("S-1"),
691 "Sprint 1",
692 None,
693 Some((start, end)),
694 )
695 .await
696 .unwrap();
697 assert_eq!(sprint.start, Some(start));
698 assert_eq!(sprint.end, Some(end));
699
700 let repo = FileRepository::new(dir.path().join(".pinto"));
702 let loaded = SprintRepository::load(&repo, &sid("S-1")).await.unwrap();
703 assert_eq!(loaded.start, Some(start));
704 assert_eq!(loaded.end, Some(end));
705 }
706
707 #[tokio::test]
708 async fn create_rejects_period_with_start_after_end() {
709 let dir = init_temp().await;
710 let start = date(2026, 7, 20);
711 let end = date(2026, 7, 6);
712 let err = create_sprint(
713 dir.path(),
714 &sid("S-1"),
715 "Sprint 1",
716 None,
717 Some((start, end)),
718 )
719 .await
720 .unwrap_err();
721 assert_eq!(
722 err,
723 Error::InvalidSprintPeriod {
724 start: start.date_naive(),
725 end: end.date_naive(),
726 }
727 );
728 }
729
730 #[tokio::test]
731 async fn create_on_uninitialized_dir_prompts_init() {
732 let dir = tempfile::TempDir::new().expect("temp dir");
733 let err = create_sprint(dir.path(), &sid("S-1"), "Sprint 1", None, None)
734 .await
735 .unwrap_err();
736 assert!(matches!(err, Error::NotInitialized { .. }), "got {err:?}");
737 }
738
739 #[tokio::test]
740 async fn start_moves_planned_to_active_and_persists() {
741 let dir = init_temp().await;
742 create_sprint(
743 dir.path(),
744 &sid("S-1"),
745 "Sprint 1",
746 Some("Ship the sprint".to_string()),
747 None,
748 )
749 .await
750 .unwrap();
751
752 let started = start_sprint(dir.path(), &sid("S-1")).await.unwrap();
753 assert_eq!(started.state, SprintState::Active);
754
755 let repo = FileRepository::new(dir.path().join(".pinto"));
756 assert_eq!(
757 SprintRepository::load(&repo, &sid("S-1"))
758 .await
759 .unwrap()
760 .state,
761 SprintState::Active
762 );
763 }
764
765 #[tokio::test]
766 async fn close_moves_active_to_closed() {
767 let dir = init_temp().await;
768 create_sprint(
769 dir.path(),
770 &sid("S-1"),
771 "Sprint 1",
772 Some("Ship the sprint".to_string()),
773 None,
774 )
775 .await
776 .unwrap();
777 start_sprint(dir.path(), &sid("S-1")).await.unwrap();
778
779 let closed = close_sprint(dir.path(), &sid("S-1"), SprintCloseAction::Retain)
780 .await
781 .unwrap();
782 assert_eq!(closed.state, SprintState::Closed);
783 }
784
785 #[tokio::test]
786 async fn close_rollover_moves_only_unfinished_items_and_snapshots_spillover() {
787 let dir = init_temp().await;
788 create_sprint(
789 dir.path(),
790 &sid("S-1"),
791 "Source",
792 Some("Ship it".to_string()),
793 None,
794 )
795 .await
796 .unwrap();
797 create_sprint(dir.path(), &sid("S-2"), "Target", None, None)
798 .await
799 .unwrap();
800 let completed = add_item(
801 dir.path(),
802 "Completed",
803 NewItem {
804 points: Some(3),
805 sprint: Some("S-1".to_string()),
806 ..NewItem::default()
807 },
808 )
809 .await
810 .unwrap();
811 let unfinished = add_item(
812 dir.path(),
813 "Unfinished",
814 NewItem {
815 points: Some(5),
816 sprint: Some("S-1".to_string()),
817 ..NewItem::default()
818 },
819 )
820 .await
821 .unwrap();
822 let unestimated = add_item(
823 dir.path(),
824 "Unestimated",
825 NewItem {
826 sprint: Some("S-1".to_string()),
827 ..NewItem::default()
828 },
829 )
830 .await
831 .unwrap();
832 let completed = move_item(dir.path(), &completed.id, "done").await.unwrap();
833 start_sprint(dir.path(), &sid("S-1")).await.unwrap();
834
835 let closed = close_sprint(
836 dir.path(),
837 &sid("S-1"),
838 SprintCloseAction::Rollover(sid("S-2")),
839 )
840 .await
841 .unwrap();
842
843 assert_eq!(
844 closed.spillover,
845 SprintSpillover {
846 points: 5,
847 items: 2,
848 unestimated_items: 1,
849 }
850 );
851 let repo = FileRepository::new(dir.path().join(".pinto"));
852 assert_eq!(
853 BacklogItemRepository::load(&repo, &completed.id)
854 .await
855 .unwrap(),
856 completed,
857 "completed PBI is not rewritten"
858 );
859 assert_eq!(
860 BacklogItemRepository::load(&repo, &unfinished.id)
861 .await
862 .unwrap()
863 .sprint
864 .as_deref(),
865 Some("S-2")
866 );
867 assert_eq!(
868 BacklogItemRepository::load(&repo, &unestimated.id)
869 .await
870 .unwrap()
871 .sprint
872 .as_deref(),
873 Some("S-2")
874 );
875 }
876
877 #[tokio::test]
878 async fn close_rejects_invalid_rollover_targets_before_mutation() {
879 let dir = init_temp().await;
880 for (id, title) in [("S-1", "Source"), ("S-2", "Closed target")] {
881 create_sprint(
882 dir.path(),
883 &sid(id),
884 title,
885 Some("Ship it".to_string()),
886 None,
887 )
888 .await
889 .unwrap();
890 }
891 let item = add_item(
892 dir.path(),
893 "Unfinished",
894 NewItem {
895 sprint: Some("S-1".to_string()),
896 ..NewItem::default()
897 },
898 )
899 .await
900 .unwrap();
901 start_sprint(dir.path(), &sid("S-1")).await.unwrap();
902 start_sprint(dir.path(), &sid("S-2")).await.unwrap();
903 close_sprint(dir.path(), &sid("S-2"), SprintCloseAction::Retain)
904 .await
905 .unwrap();
906
907 assert_eq!(
908 close_sprint(
909 dir.path(),
910 &sid("S-1"),
911 SprintCloseAction::Rollover(sid("S-404")),
912 )
913 .await
914 .unwrap_err(),
915 Error::SprintNotFound(sid("S-404"))
916 );
917 assert!(matches!(
918 close_sprint(
919 dir.path(),
920 &sid("S-1"),
921 SprintCloseAction::Rollover(sid("S-1")),
922 )
923 .await
924 .unwrap_err(),
925 Error::InvalidFilterOption(message) if message.contains("itself")
926 ));
927 assert_eq!(
928 close_sprint(
929 dir.path(),
930 &sid("S-1"),
931 SprintCloseAction::Rollover(sid("S-2")),
932 )
933 .await
934 .unwrap_err(),
935 Error::SprintClosed(sid("S-2"))
936 );
937
938 let repo = FileRepository::new(dir.path().join(".pinto"));
939 assert_eq!(
940 SprintRepository::load(&repo, &sid("S-1"))
941 .await
942 .unwrap()
943 .state,
944 SprintState::Active
945 );
946 assert_eq!(
947 BacklogItemRepository::load(&repo, &item.id)
948 .await
949 .unwrap()
950 .sprint
951 .as_deref(),
952 Some("S-1")
953 );
954 }
955
956 #[tokio::test]
957 async fn close_from_planned_is_rejected() {
958 let dir = init_temp().await;
959 create_sprint(dir.path(), &sid("S-1"), "Sprint 1", None, None)
960 .await
961 .unwrap();
962
963 let err = close_sprint(dir.path(), &sid("S-1"), SprintCloseAction::Retain)
964 .await
965 .unwrap_err();
966 assert!(
967 matches!(err, Error::InvalidSprintTransition { .. }),
968 "got {err:?}"
969 );
970 }
971
972 #[tokio::test]
973 async fn start_missing_sprint_returns_not_found() {
974 let dir = init_temp().await;
975 let err = start_sprint(dir.path(), &sid("S-9")).await.unwrap_err();
976 assert_eq!(err, Error::SprintNotFound(sid("S-9")));
977 }
978
979 #[tokio::test]
980 async fn assign_sets_item_sprint_and_persists() {
981 let dir = init_temp().await;
982 create_sprint(dir.path(), &sid("S-1"), "Sprint 1", None, None)
983 .await
984 .unwrap();
985 let item = add_item(dir.path(), "Task", NewItem::default())
986 .await
987 .unwrap();
988
989 let assigned = assign_sprint(dir.path(), &sid("S-1"), &item.id)
990 .await
991 .expect("assign succeeds");
992 assert_eq!(assigned.sprint.as_deref(), Some("S-1"));
993
994 let repo = FileRepository::new(dir.path().join(".pinto"));
995 assert_eq!(
996 BacklogItemRepository::load(&repo, &item.id)
997 .await
998 .unwrap()
999 .sprint
1000 .as_deref(),
1001 Some("S-1")
1002 );
1003 }
1004
1005 #[tokio::test]
1006 async fn assign_to_missing_sprint_returns_not_found() {
1007 let dir = init_temp().await;
1008 let item = add_item(dir.path(), "Task", NewItem::default())
1009 .await
1010 .unwrap();
1011
1012 let err = assign_sprint(dir.path(), &sid("S-9"), &item.id)
1013 .await
1014 .unwrap_err();
1015 assert_eq!(err, Error::SprintNotFound(sid("S-9")));
1016 let repo = FileRepository::new(dir.path().join(".pinto"));
1018 assert_eq!(
1019 BacklogItemRepository::load(&repo, &item.id)
1020 .await
1021 .unwrap()
1022 .sprint,
1023 None
1024 );
1025 }
1026
1027 #[tokio::test]
1028 async fn assign_missing_item_returns_not_found() {
1029 let dir = init_temp().await;
1030 create_sprint(dir.path(), &sid("S-1"), "Sprint 1", None, None)
1031 .await
1032 .unwrap();
1033 let err = assign_sprint(dir.path(), &sid("S-1"), &ItemId::new("T", 99))
1034 .await
1035 .unwrap_err();
1036 assert!(matches!(err, Error::NotFound(_)), "got {err:?}");
1037 }
1038
1039 #[tokio::test]
1040 async fn bulk_assignment_selects_matching_items_in_rank_order_up_to_limit() {
1041 let dir = init_temp().await;
1042 create_sprint(dir.path(), &sid("S-1"), "Sprint 1", None, None)
1043 .await
1044 .unwrap();
1045 for title in ["First", "Second", "Third"] {
1046 add_item(dir.path(), title, NewItem::default())
1047 .await
1048 .unwrap();
1049 }
1050 move_item(dir.path(), &ItemId::new("T", 3), "in-progress")
1051 .await
1052 .unwrap();
1053
1054 let assigned = assign_sprint_by_status(dir.path(), &sid("S-1"), "todo", Some(2))
1055 .await
1056 .expect("bulk assignment succeeds");
1057
1058 assert_eq!(
1059 assigned
1060 .iter()
1061 .map(|item| item.id.to_string())
1062 .collect::<Vec<_>>(),
1063 ["T-1", "T-2"]
1064 );
1065 let repo = FileRepository::new(dir.path().join(".pinto"));
1066 assert_eq!(
1067 BacklogItemRepository::load(&repo, &ItemId::new("T", 1))
1068 .await
1069 .unwrap()
1070 .sprint
1071 .as_deref(),
1072 Some("S-1")
1073 );
1074 assert_eq!(
1075 BacklogItemRepository::load(&repo, &ItemId::new("T", 2))
1076 .await
1077 .unwrap()
1078 .sprint
1079 .as_deref(),
1080 Some("S-1")
1081 );
1082 assert_eq!(
1083 BacklogItemRepository::load(&repo, &ItemId::new("T", 3))
1084 .await
1085 .unwrap()
1086 .sprint,
1087 None
1088 );
1089 }
1090
1091 #[tokio::test]
1092 async fn bulk_assignment_without_limit_assigns_all_matching_items_and_skips_target_members() {
1093 let dir = init_temp().await;
1094 create_sprint(dir.path(), &sid("S-1"), "Sprint 1", None, None)
1095 .await
1096 .unwrap();
1097 for title in ["First", "Second", "Third"] {
1098 add_item(dir.path(), title, NewItem::default())
1099 .await
1100 .unwrap();
1101 }
1102 assign_sprint(dir.path(), &sid("S-1"), &ItemId::new("T", 1))
1103 .await
1104 .unwrap();
1105 move_item(dir.path(), &ItemId::new("T", 3), "in-progress")
1106 .await
1107 .unwrap();
1108
1109 let assigned = assign_sprint_by_status(dir.path(), &sid("S-1"), "todo", None)
1110 .await
1111 .expect("bulk assignment succeeds");
1112
1113 assert_eq!(
1114 assigned
1115 .iter()
1116 .map(|item| item.id.to_string())
1117 .collect::<Vec<_>>(),
1118 ["T-2"]
1119 );
1120 }
1121
1122 #[tokio::test]
1123 async fn bulk_assignment_limit_does_not_count_target_members() {
1124 let dir = init_temp().await;
1125 create_sprint(dir.path(), &sid("S-1"), "Sprint 1", None, None)
1126 .await
1127 .unwrap();
1128 for title in ["Already assigned", "Next in rank"] {
1129 add_item(dir.path(), title, NewItem::default())
1130 .await
1131 .unwrap();
1132 }
1133 assign_sprint(dir.path(), &sid("S-1"), &ItemId::new("T", 1))
1134 .await
1135 .unwrap();
1136
1137 let assigned = assign_sprint_by_status(dir.path(), &sid("S-1"), "todo", Some(1))
1138 .await
1139 .expect("bulk assignment succeeds");
1140
1141 assert_eq!(
1142 assigned
1143 .iter()
1144 .map(|item| item.id.to_string())
1145 .collect::<Vec<_>>(),
1146 ["T-2"]
1147 );
1148 }
1149
1150 #[tokio::test]
1151 async fn bulk_assignment_rejects_other_sprint_without_partial_assignment() {
1152 let dir = init_temp().await;
1153 create_sprint(dir.path(), &sid("S-1"), "Sprint 1", None, None)
1154 .await
1155 .unwrap();
1156 create_sprint(dir.path(), &sid("S-2"), "Sprint 2", None, None)
1157 .await
1158 .unwrap();
1159 for title in ["Already assigned", "Still todo"] {
1160 add_item(dir.path(), title, NewItem::default())
1161 .await
1162 .unwrap();
1163 }
1164 assign_sprint(dir.path(), &sid("S-2"), &ItemId::new("T", 1))
1165 .await
1166 .unwrap();
1167
1168 let err = assign_sprint_by_status(dir.path(), &sid("S-1"), "todo", None)
1169 .await
1170 .unwrap_err();
1171 assert!(
1172 matches!(&err, Error::InvalidFilterOption(message) if message.contains("T-1") && message.contains("S-2")),
1173 "got {err:?}"
1174 );
1175
1176 let repo = FileRepository::new(dir.path().join(".pinto"));
1177 assert_eq!(
1178 BacklogItemRepository::load(&repo, &ItemId::new("T", 2))
1179 .await
1180 .unwrap()
1181 .sprint,
1182 None
1183 );
1184 }
1185
1186 #[tokio::test]
1187 async fn bulk_assignment_rejects_unknown_status_and_zero_limit_without_changes() {
1188 let dir = init_temp().await;
1189 create_sprint(dir.path(), &sid("S-1"), "Sprint 1", None, None)
1190 .await
1191 .unwrap();
1192 let item = add_item(dir.path(), "Task", NewItem::default())
1193 .await
1194 .unwrap();
1195
1196 assert_eq!(
1197 assign_sprint_by_status(dir.path(), &sid("S-1"), "missing", None)
1198 .await
1199 .unwrap_err(),
1200 Error::UnknownStatus("missing".to_string())
1201 );
1202 assert!(matches!(
1203 assign_sprint_by_status(dir.path(), &sid("S-1"), "todo", Some(0))
1204 .await
1205 .unwrap_err(),
1206 Error::InvalidFilterOption(message) if message.contains("limit")
1207 ));
1208
1209 let repo = FileRepository::new(dir.path().join(".pinto"));
1210 assert_eq!(
1211 BacklogItemRepository::load(&repo, &item.id)
1212 .await
1213 .unwrap()
1214 .sprint,
1215 None
1216 );
1217 }
1218
1219 #[tokio::test]
1220 async fn bulk_assignment_rejects_missing_or_closed_sprint_without_changes() {
1221 let dir = init_temp().await;
1222 let item = add_item(dir.path(), "Task", NewItem::default())
1223 .await
1224 .unwrap();
1225
1226 assert_eq!(
1227 assign_sprint_by_status(dir.path(), &sid("S-9"), "todo", None)
1228 .await
1229 .unwrap_err(),
1230 Error::SprintNotFound(sid("S-9"))
1231 );
1232
1233 create_sprint(
1234 dir.path(),
1235 &sid("S-1"),
1236 "Sprint 1",
1237 Some("Ship it".to_string()),
1238 None,
1239 )
1240 .await
1241 .unwrap();
1242 start_sprint(dir.path(), &sid("S-1")).await.unwrap();
1243 close_sprint(dir.path(), &sid("S-1"), SprintCloseAction::Retain)
1244 .await
1245 .unwrap();
1246 assert_eq!(
1247 assign_sprint_by_status(dir.path(), &sid("S-1"), "todo", None)
1248 .await
1249 .unwrap_err(),
1250 Error::SprintClosed(sid("S-1"))
1251 );
1252
1253 let repo = FileRepository::new(dir.path().join(".pinto"));
1254 assert_eq!(
1255 BacklogItemRepository::load(&repo, &item.id)
1256 .await
1257 .unwrap()
1258 .sprint,
1259 None
1260 );
1261 }
1262
1263 #[tokio::test]
1264 async fn unassign_clears_item_sprint() {
1265 let dir = init_temp().await;
1266 create_sprint(dir.path(), &sid("S-1"), "Sprint 1", None, None)
1267 .await
1268 .unwrap();
1269 let item = add_item(dir.path(), "Task", NewItem::default())
1270 .await
1271 .unwrap();
1272 assign_sprint(dir.path(), &sid("S-1"), &item.id)
1273 .await
1274 .unwrap();
1275
1276 let cleared = unassign_sprint(dir.path(), &sid("S-1"), &item.id)
1277 .await
1278 .expect("unassign succeeds");
1279 assert_eq!(cleared.sprint, None);
1280 }
1281
1282 #[tokio::test]
1283 async fn unassign_item_not_in_sprint_returns_error() {
1284 let dir = init_temp().await;
1285 create_sprint(dir.path(), &sid("S-1"), "Sprint 1", None, None)
1286 .await
1287 .unwrap();
1288 let item = add_item(dir.path(), "Task", NewItem::default())
1289 .await
1290 .unwrap();
1291
1292 let err = unassign_sprint(dir.path(), &sid("S-1"), &item.id)
1293 .await
1294 .unwrap_err();
1295 assert_eq!(
1296 err,
1297 Error::NotInSprint {
1298 item: item.id,
1299 sprint: sid("S-1"),
1300 }
1301 );
1302 }
1303
1304 #[tokio::test]
1305 async fn list_returns_sprints_in_creation_order() {
1306 let dir = init_temp().await;
1307 create_sprint(dir.path(), &sid("S-1"), "First", None, None)
1308 .await
1309 .unwrap();
1310 create_sprint(dir.path(), &sid("S-2"), "Second", None, None)
1311 .await
1312 .unwrap();
1313
1314 let ids: Vec<String> = list_sprints(dir.path())
1315 .await
1316 .expect("list succeeds")
1317 .into_iter()
1318 .map(|s| s.id.as_str().to_string())
1319 .collect();
1320 assert_eq!(ids, ["S-1", "S-2"]);
1321 }
1322
1323 #[tokio::test]
1324 async fn list_on_empty_board_is_empty() {
1325 let dir = init_temp().await;
1326 assert!(list_sprints(dir.path()).await.unwrap().is_empty());
1327 }
1328
1329 fn sprint_with_capacity(id: &str, hours: f64) -> Sprint {
1330 let mut sprint = Sprint::new(sid(id), id, date(2026, 7, 1)).expect("valid sprint");
1331 sprint.start = Some(date(2026, 7, 1));
1332 sprint.end = Some(date(2026, 7, 1));
1333 sprint.set_capacity(hours, 0, 1.0).expect("valid capacity");
1334 sprint
1335 }
1336
1337 fn sprint_with_state(id: &str, state: SprintState) -> Sprint {
1338 let mut sprint = Sprint::new(sid(id), id, date(2026, 7, 1)).expect("valid sprint");
1339 sprint.goal = "Ship it".to_string();
1340 if matches!(state, SprintState::Active | SprintState::Closed) {
1341 sprint.start(date(2026, 7, 2)).expect("start sprint");
1342 }
1343 if state == SprintState::Closed {
1344 sprint
1345 .close(date(2026, 7, 3), SprintSpillover::default())
1346 .expect("close sprint");
1347 }
1348 sprint
1349 }
1350
1351 fn item_in_sprint(
1352 number: u32,
1353 sprint: &str,
1354 points: Option<u32>,
1355 done_at: Option<DateTime<Utc>>,
1356 ) -> BacklogItem {
1357 let mut item = BacklogItem::new(
1358 ItemId::new("T", number),
1359 format!("Item {number}"),
1360 crate::backlog::Status::new("todo"),
1361 crate::rank::Rank::after(None),
1362 date(2026, 7, 1),
1363 )
1364 .expect("valid item");
1365 item.sprint = Some(sprint.to_string());
1366 item.points = points;
1367 item.done_at = done_at;
1368 item
1369 }
1370
1371 #[test]
1372 fn sprint_load_warning_respects_capacity_equality_and_ignores_unestimated_points() {
1373 let target = sprint_with_capacity("S-2", 5.0);
1374 let items = [
1375 item_in_sprint(1, "S-2", Some(2), None),
1376 item_in_sprint(2, "S-2", Some(3), None),
1377 item_in_sprint(3, "S-2", None, None),
1378 ];
1379
1380 assert!(
1381 sprint_load_warnings_for(&target, std::slice::from_ref(&target), &items).is_empty()
1382 );
1383
1384 let over = [
1385 item_in_sprint(1, "S-2", Some(3), None),
1386 item_in_sprint(2, "S-2", Some(3), None),
1387 item_in_sprint(3, "S-2", None, None),
1388 ];
1389 assert_eq!(
1390 sprint_load_warnings_for(&target, std::slice::from_ref(&target), &over),
1391 vec![SprintLoadWarning {
1392 kind: SprintLoadWarningKind::Capacity,
1393 points: 6,
1394 threshold: 5.0,
1395 }]
1396 );
1397 }
1398
1399 #[test]
1400 fn sprint_load_warning_uses_recent_closed_sprint_velocity() {
1401 let first = sprint_with_state("S-1", SprintState::Closed);
1402 let second = sprint_with_state("S-2", SprintState::Closed);
1403 let target = sprint_with_capacity("S-3", 100.0);
1404 let sprints = [first, second, target.clone()];
1405 let items = [
1406 item_in_sprint(1, "S-1", Some(4), Some(date(2026, 7, 2))),
1407 item_in_sprint(2, "S-2", Some(6), Some(date(2026, 7, 2))),
1408 item_in_sprint(3, "S-3", Some(6), None),
1409 ];
1410
1411 assert_eq!(
1412 sprint_load_warnings_for(&target, &sprints, &items),
1413 vec![SprintLoadWarning {
1414 kind: SprintLoadWarningKind::Velocity,
1415 points: 6,
1416 threshold: 5.0,
1417 }]
1418 );
1419 }
1420
1421 #[test]
1422 fn sprint_load_warning_is_empty_without_capacity_or_history() {
1423 let target = Sprint::new(sid("S-1"), "S-1", date(2026, 7, 1)).expect("valid sprint");
1424 let items = [item_in_sprint(1, "S-1", Some(8), None)];
1425
1426 assert!(
1427 sprint_load_warnings_for(&target, std::slice::from_ref(&target), &items).is_empty()
1428 );
1429 }
1430}