1use super::{open_board, open_board_locked};
8use crate::backlog::{BacklogItem, ItemId};
9use crate::error::{Error, Result};
10use crate::sprint::{Sprint, SprintCapacity, SprintId, SprintState};
11use crate::storage::{Backend, BacklogItemRepository, SprintRepository};
12use chrono::{DateTime, Utc};
13use std::path::Path;
14
15pub async fn create_sprint(
24 project_dir: &Path,
25 id: &SprintId,
26 title: &str,
27 goal: Option<String>,
28 period: Option<(DateTime<Utc>, DateTime<Utc>)>,
29) -> Result<Sprint> {
30 if let Some((start, end)) = period
32 && start > end
33 {
34 return Err(Error::InvalidSprintPeriod {
35 start: start.date_naive(),
36 end: end.date_naive(),
37 });
38 }
39
40 let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
41
42 match SprintRepository::load(&repo, id).await {
44 Ok(_) => return Err(Error::SprintExists(id.clone())),
45 Err(Error::SprintNotFound(_)) => {}
46 Err(e) => return Err(e),
47 }
48
49 let mut sprint = Sprint::new(id.clone(), title, Utc::now())?;
50 if let Some(goal) = goal {
51 sprint.goal = goal;
52 }
53 if let Some((start, end)) = period {
54 sprint.start = Some(start);
55 sprint.end = Some(end);
56 }
57 SprintRepository::save(&repo, &sprint).await?;
58 repo.commit(&format!("pinto: add {}", sprint.id)).await?;
59 Ok(sprint)
60}
61
62pub async fn edit_sprint(
68 project_dir: &Path,
69 id: &SprintId,
70 title: Option<String>,
71 goal: Option<String>,
72 period: Option<(DateTime<Utc>, DateTime<Utc>)>,
73) -> Result<Sprint> {
74 let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
75 let mut sprint = SprintRepository::load(&repo, id).await?;
76 sprint.update_details(title, goal, period, Utc::now())?;
77 SprintRepository::save(&repo, &sprint).await?;
78 repo.commit(&format!("pinto: update {}", sprint.id)).await?;
79 Ok(sprint)
80}
81
82pub async fn delete_sprint(project_dir: &Path, id: &SprintId) -> Result<()> {
87 let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
88 SprintRepository::load(&repo, id).await?;
89
90 let now = Utc::now();
91 let assigned = BacklogItemRepository::list(&repo)
92 .await?
93 .into_iter()
94 .filter(|item| item.sprint.as_deref() == Some(id.as_str()))
95 .collect::<Vec<_>>();
96 for mut item in assigned {
97 item.sprint = None;
98 item.updated = now;
99 BacklogItemRepository::save(&repo, &item).await?;
100 }
101
102 SprintRepository::delete(&repo, id).await?;
103 repo.commit(&format!("pinto: delete {id}")).await?;
104 Ok(())
105}
106
107pub async fn start_sprint(project_dir: &Path, id: &SprintId) -> Result<Sprint> {
113 transition_sprint(project_dir, id, Sprint::start).await
114}
115
116pub async fn close_sprint(project_dir: &Path, id: &SprintId) -> Result<Sprint> {
122 transition_sprint(project_dir, id, Sprint::close).await
123}
124
125async fn transition_sprint(
130 project_dir: &Path,
131 id: &SprintId,
132 transition: impl FnOnce(&mut Sprint, chrono::DateTime<Utc>) -> Result<()>,
133) -> Result<Sprint> {
134 let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
135 let mut sprint = SprintRepository::load(&repo, id).await?;
136 transition(&mut sprint, Utc::now())?;
137 SprintRepository::save(&repo, &sprint).await?;
138 repo.commit(&format!("pinto: update {}", sprint.id)).await?;
139 Ok(sprint)
140}
141
142pub(crate) async fn validate_sprint_assignment(repo: &Backend, raw: &str) -> Result<SprintId> {
144 let id = SprintId::new(raw)?;
145 let sprint = SprintRepository::load(repo, &id).await?;
146 if sprint.state == SprintState::Closed {
147 return Err(Error::SprintClosed(id));
148 }
149 Ok(id)
150}
151
152pub async fn assign_sprint(
160 project_dir: &Path,
161 sprint_id: &SprintId,
162 item_id: &ItemId,
163) -> Result<BacklogItem> {
164 assign_sprint_raw(project_dir, sprint_id.as_str(), item_id).await
165}
166
167pub async fn assign_sprint_raw(
169 project_dir: &Path,
170 raw_sprint_id: &str,
171 item_id: &ItemId,
172) -> Result<BacklogItem> {
173 let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
174 let sprint_id = validate_sprint_assignment(&repo, raw_sprint_id).await?;
175 let mut item = BacklogItemRepository::load(&repo, item_id).await?;
176 item.sprint = Some(sprint_id.to_string());
177 item.updated = Utc::now();
178 BacklogItemRepository::save(&repo, &item).await?;
179 repo.commit(&format!("pinto: update {}", item.id)).await?;
180 Ok(item)
181}
182
183pub async fn assign_sprint_by_status(
191 project_dir: &Path,
192 sprint_id: &SprintId,
193 status: &str,
194 limit: Option<usize>,
195) -> Result<Vec<BacklogItem>> {
196 let (_board_dir, repo, config, _lock) = open_board_locked(project_dir).await?;
197
198 if !config.columns.iter().any(|column| column == status) {
199 return Err(Error::UnknownStatus(status.to_string()));
200 }
201 if limit == Some(0) {
202 return Err(Error::InvalidFilterOption(
203 "--limit must be at least 1".to_string(),
204 ));
205 }
206 validate_sprint_assignment(&repo, sprint_id.as_str()).await?;
207
208 let mut candidates = BacklogItemRepository::list(&repo)
211 .await?
212 .into_iter()
213 .filter(|item| item.status.as_str() == status)
214 .filter(|item| item.sprint.as_deref() != Some(sprint_id.as_str()))
215 .collect::<Vec<_>>();
216 if let Some(limit) = limit {
217 candidates.truncate(limit);
218 }
219
220 if let Some(item) = candidates.iter().find(|item| item.sprint.is_some())
223 && let Some(assigned_sprint) = item.sprint.as_deref()
224 {
225 return Err(Error::InvalidFilterOption(format!(
226 "{} is already assigned to sprint {}; remove it before bulk assignment",
227 item.id, assigned_sprint
228 )));
229 }
230
231 let original = candidates.clone();
232 let now = Utc::now();
233 let mut assigned = Vec::with_capacity(candidates.len());
234 for (index, mut item) in candidates.into_iter().enumerate() {
235 item.sprint = Some(sprint_id.to_string());
236 item.updated = now;
237 if let Err(error) = BacklogItemRepository::save(&repo, &item).await {
238 rollback_bulk_assignment(&repo, &original[..=index], &error).await?;
239 return Err(error);
240 }
241 assigned.push(item);
242 }
243 if !assigned.is_empty()
244 && let Err(error) = repo
245 .commit(&format!(
246 "pinto: assign {} item(s) to {}",
247 assigned.len(),
248 sprint_id
249 ))
250 .await
251 {
252 rollback_bulk_assignment(&repo, &original, &error).await?;
253 return Err(error);
254 }
255 Ok(assigned)
256}
257
258async fn rollback_bulk_assignment(
260 repo: &Backend,
261 original: &[BacklogItem],
262 operation_error: &Error,
263) -> Result<()> {
264 for item in original.iter().rev() {
265 if let Err(rollback_error) = BacklogItemRepository::save(repo, item).await {
266 return Err(Error::InvalidFilterOption(format!(
267 "{operation_error}; failed to roll back bulk assignment: {rollback_error}"
268 )));
269 }
270 }
271 Ok(())
272}
273
274pub async fn unassign_sprint(
280 project_dir: &Path,
281 sprint_id: &SprintId,
282 item_id: &ItemId,
283) -> Result<BacklogItem> {
284 let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
285 let mut item = BacklogItemRepository::load(&repo, item_id).await?;
286 if item.sprint.as_deref() != Some(sprint_id.as_str()) {
287 return Err(Error::NotInSprint {
288 item: item_id.clone(),
289 sprint: sprint_id.clone(),
290 });
291 }
292 item.sprint = None;
293 item.updated = Utc::now();
294 BacklogItemRepository::save(&repo, &item).await?;
295 repo.commit(&format!("pinto: update {}", item.id)).await?;
296 Ok(item)
297}
298
299pub async fn list_sprints(project_dir: &Path) -> Result<Vec<Sprint>> {
303 let (_board_dir, repo, _config) = open_board(project_dir).await?;
304 SprintRepository::list(&repo).await
305}
306
307pub async fn set_sprint_capacity(
309 project_dir: &Path,
310 id: &SprintId,
311 daily_work_hours: f64,
312 holiday_days: u32,
313 deduction_factor: f64,
314) -> Result<SprintCapacity> {
315 let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
316 let mut sprint = SprintRepository::load(&repo, id).await?;
317 sprint.set_capacity(daily_work_hours, holiday_days, deduction_factor)?;
318 sprint.updated = Utc::now();
319 let capacity = sprint
320 .capacity()
321 .ok_or_else(|| Error::SprintCapacityUnset(id.clone()))?;
322 SprintRepository::save(&repo, &sprint).await?;
323 repo.commit(&format!("pinto: update {}", sprint.id)).await?;
324 Ok(capacity)
325}
326
327pub async fn sprint_capacity(project_dir: &Path, id: &SprintId) -> Result<SprintCapacity> {
329 let (_board_dir, repo, _config) = open_board(project_dir).await?;
330 let sprint = SprintRepository::load(&repo, id).await?;
331 sprint
332 .capacity()
333 .ok_or_else(|| Error::SprintCapacityUnset(id.clone()))
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339 use crate::service::test_support::init_temp;
340 use crate::service::{NewItem, add_item, move_item};
341 use crate::sprint::SprintState;
342 use crate::storage::{Backend, FileRepository};
343
344 fn sid(s: &str) -> SprintId {
345 SprintId::new(s).expect("valid sprint id")
346 }
347
348 #[tokio::test]
349 async fn sprint_assignment_validation_checks_grammar_and_existence() {
350 let dir = init_temp().await;
351 create_sprint(dir.path(), &sid("S-1"), "Sprint 1", None, None)
352 .await
353 .unwrap();
354 create_sprint(
355 dir.path(),
356 &sid("S-2"),
357 "Sprint 2",
358 Some("Ship the sprint".to_string()),
359 None,
360 )
361 .await
362 .unwrap();
363 start_sprint(dir.path(), &sid("S-2")).await.unwrap();
364 close_sprint(dir.path(), &sid("S-2")).await.unwrap();
365 let repo = Backend::File(FileRepository::new(dir.path().join(".pinto")));
366
367 assert_eq!(
368 validate_sprint_assignment(&repo, "S 1").await.unwrap_err(),
369 Error::InvalidSprintId("S 1".to_string())
370 );
371 assert_eq!(
372 validate_sprint_assignment(&repo, "S-9").await.unwrap_err(),
373 Error::SprintNotFound(sid("S-9"))
374 );
375 assert_eq!(
376 validate_sprint_assignment(&repo, "S-1")
377 .await
378 .expect("existing sprint validates"),
379 sid("S-1")
380 );
381 assert_eq!(
382 validate_sprint_assignment(&repo, "S-2").await.unwrap_err(),
383 Error::SprintClosed(sid("S-2"))
384 );
385 }
386
387 fn date(y: i32, m: u32, d: u32) -> DateTime<Utc> {
389 chrono::NaiveDate::from_ymd_opt(y, m, d)
390 .expect("valid date")
391 .and_hms_opt(0, 0, 0)
392 .expect("valid time")
393 .and_utc()
394 }
395
396 #[tokio::test]
397 async fn create_persists_planned_sprint() {
398 let dir = init_temp().await;
399
400 let sprint = create_sprint(dir.path(), &sid("S-1"), "Sprint 1", None, None)
401 .await
402 .expect("create succeeds");
403 assert_eq!(sprint.title, "Sprint 1");
404 assert_eq!(sprint.state, SprintState::Planned);
405
406 let repo = FileRepository::new(dir.path().join(".pinto"));
408 let loaded = SprintRepository::load(&repo, &sid("S-1")).await.unwrap();
409 assert_eq!(loaded, sprint);
410 }
411
412 #[tokio::test]
413 async fn create_stores_goal_as_body() {
414 let dir = init_temp().await;
415 let sprint = create_sprint(
416 dir.path(),
417 &sid("S-1"),
418 "Sprint 1",
419 Some("Ship the MVP".to_string()),
420 None,
421 )
422 .await
423 .unwrap();
424 assert_eq!(sprint.goal, "Ship the MVP");
425 }
426
427 #[tokio::test]
428 async fn create_rejects_duplicate_id() {
429 let dir = init_temp().await;
430 create_sprint(dir.path(), &sid("S-1"), "First", None, None)
431 .await
432 .unwrap();
433
434 let err = create_sprint(dir.path(), &sid("S-1"), "Second", None, None)
435 .await
436 .unwrap_err();
437 assert_eq!(err, Error::SprintExists(sid("S-1")));
438
439 let repo = FileRepository::new(dir.path().join(".pinto"));
441 assert_eq!(
442 SprintRepository::load(&repo, &sid("S-1"))
443 .await
444 .unwrap()
445 .title,
446 "First"
447 );
448 }
449
450 #[tokio::test]
451 async fn create_rejects_empty_title() {
452 let dir = init_temp().await;
453 let err = create_sprint(dir.path(), &sid("S-1"), " ", None, None)
454 .await
455 .unwrap_err();
456 assert_eq!(err, Error::EmptySprintTitle);
457 }
458
459 #[tokio::test]
460 async fn create_stores_planned_period() {
461 let dir = init_temp().await;
462 let start = date(2026, 7, 6);
463 let end = date(2026, 7, 20);
464 let sprint = create_sprint(
465 dir.path(),
466 &sid("S-1"),
467 "Sprint 1",
468 None,
469 Some((start, end)),
470 )
471 .await
472 .unwrap();
473 assert_eq!(sprint.start, Some(start));
474 assert_eq!(sprint.end, Some(end));
475
476 let repo = FileRepository::new(dir.path().join(".pinto"));
478 let loaded = SprintRepository::load(&repo, &sid("S-1")).await.unwrap();
479 assert_eq!(loaded.start, Some(start));
480 assert_eq!(loaded.end, Some(end));
481 }
482
483 #[tokio::test]
484 async fn create_rejects_period_with_start_after_end() {
485 let dir = init_temp().await;
486 let start = date(2026, 7, 20);
487 let end = date(2026, 7, 6);
488 let err = create_sprint(
489 dir.path(),
490 &sid("S-1"),
491 "Sprint 1",
492 None,
493 Some((start, end)),
494 )
495 .await
496 .unwrap_err();
497 assert_eq!(
498 err,
499 Error::InvalidSprintPeriod {
500 start: start.date_naive(),
501 end: end.date_naive(),
502 }
503 );
504 }
505
506 #[tokio::test]
507 async fn create_on_uninitialized_dir_prompts_init() {
508 let dir = tempfile::TempDir::new().expect("temp dir");
509 let err = create_sprint(dir.path(), &sid("S-1"), "Sprint 1", None, None)
510 .await
511 .unwrap_err();
512 assert!(matches!(err, Error::NotInitialized { .. }), "got {err:?}");
513 }
514
515 #[tokio::test]
516 async fn start_moves_planned_to_active_and_persists() {
517 let dir = init_temp().await;
518 create_sprint(
519 dir.path(),
520 &sid("S-1"),
521 "Sprint 1",
522 Some("Ship the sprint".to_string()),
523 None,
524 )
525 .await
526 .unwrap();
527
528 let started = start_sprint(dir.path(), &sid("S-1")).await.unwrap();
529 assert_eq!(started.state, SprintState::Active);
530
531 let repo = FileRepository::new(dir.path().join(".pinto"));
532 assert_eq!(
533 SprintRepository::load(&repo, &sid("S-1"))
534 .await
535 .unwrap()
536 .state,
537 SprintState::Active
538 );
539 }
540
541 #[tokio::test]
542 async fn close_moves_active_to_closed() {
543 let dir = init_temp().await;
544 create_sprint(
545 dir.path(),
546 &sid("S-1"),
547 "Sprint 1",
548 Some("Ship the sprint".to_string()),
549 None,
550 )
551 .await
552 .unwrap();
553 start_sprint(dir.path(), &sid("S-1")).await.unwrap();
554
555 let closed = close_sprint(dir.path(), &sid("S-1")).await.unwrap();
556 assert_eq!(closed.state, SprintState::Closed);
557 }
558
559 #[tokio::test]
560 async fn close_from_planned_is_rejected() {
561 let dir = init_temp().await;
562 create_sprint(dir.path(), &sid("S-1"), "Sprint 1", None, None)
563 .await
564 .unwrap();
565
566 let err = close_sprint(dir.path(), &sid("S-1")).await.unwrap_err();
567 assert!(
568 matches!(err, Error::InvalidSprintTransition { .. }),
569 "got {err:?}"
570 );
571 }
572
573 #[tokio::test]
574 async fn start_missing_sprint_returns_not_found() {
575 let dir = init_temp().await;
576 let err = start_sprint(dir.path(), &sid("S-9")).await.unwrap_err();
577 assert_eq!(err, Error::SprintNotFound(sid("S-9")));
578 }
579
580 #[tokio::test]
581 async fn assign_sets_item_sprint_and_persists() {
582 let dir = init_temp().await;
583 create_sprint(dir.path(), &sid("S-1"), "Sprint 1", None, None)
584 .await
585 .unwrap();
586 let item = add_item(dir.path(), "Task", NewItem::default())
587 .await
588 .unwrap();
589
590 let assigned = assign_sprint(dir.path(), &sid("S-1"), &item.id)
591 .await
592 .expect("assign succeeds");
593 assert_eq!(assigned.sprint.as_deref(), Some("S-1"));
594
595 let repo = FileRepository::new(dir.path().join(".pinto"));
596 assert_eq!(
597 BacklogItemRepository::load(&repo, &item.id)
598 .await
599 .unwrap()
600 .sprint
601 .as_deref(),
602 Some("S-1")
603 );
604 }
605
606 #[tokio::test]
607 async fn assign_to_missing_sprint_returns_not_found() {
608 let dir = init_temp().await;
609 let item = add_item(dir.path(), "Task", NewItem::default())
610 .await
611 .unwrap();
612
613 let err = assign_sprint(dir.path(), &sid("S-9"), &item.id)
614 .await
615 .unwrap_err();
616 assert_eq!(err, Error::SprintNotFound(sid("S-9")));
617 let repo = FileRepository::new(dir.path().join(".pinto"));
619 assert_eq!(
620 BacklogItemRepository::load(&repo, &item.id)
621 .await
622 .unwrap()
623 .sprint,
624 None
625 );
626 }
627
628 #[tokio::test]
629 async fn assign_missing_item_returns_not_found() {
630 let dir = init_temp().await;
631 create_sprint(dir.path(), &sid("S-1"), "Sprint 1", None, None)
632 .await
633 .unwrap();
634 let err = assign_sprint(dir.path(), &sid("S-1"), &ItemId::new("T", 99))
635 .await
636 .unwrap_err();
637 assert!(matches!(err, Error::NotFound(_)), "got {err:?}");
638 }
639
640 #[tokio::test]
641 async fn bulk_assignment_selects_matching_items_in_rank_order_up_to_limit() {
642 let dir = init_temp().await;
643 create_sprint(dir.path(), &sid("S-1"), "Sprint 1", None, None)
644 .await
645 .unwrap();
646 for title in ["First", "Second", "Third"] {
647 add_item(dir.path(), title, NewItem::default())
648 .await
649 .unwrap();
650 }
651 move_item(dir.path(), &ItemId::new("T", 3), "in-progress")
652 .await
653 .unwrap();
654
655 let assigned = assign_sprint_by_status(dir.path(), &sid("S-1"), "todo", Some(2))
656 .await
657 .expect("bulk assignment succeeds");
658
659 assert_eq!(
660 assigned
661 .iter()
662 .map(|item| item.id.to_string())
663 .collect::<Vec<_>>(),
664 ["T-1", "T-2"]
665 );
666 let repo = FileRepository::new(dir.path().join(".pinto"));
667 assert_eq!(
668 BacklogItemRepository::load(&repo, &ItemId::new("T", 1))
669 .await
670 .unwrap()
671 .sprint
672 .as_deref(),
673 Some("S-1")
674 );
675 assert_eq!(
676 BacklogItemRepository::load(&repo, &ItemId::new("T", 2))
677 .await
678 .unwrap()
679 .sprint
680 .as_deref(),
681 Some("S-1")
682 );
683 assert_eq!(
684 BacklogItemRepository::load(&repo, &ItemId::new("T", 3))
685 .await
686 .unwrap()
687 .sprint,
688 None
689 );
690 }
691
692 #[tokio::test]
693 async fn bulk_assignment_without_limit_assigns_all_matching_items_and_skips_target_members() {
694 let dir = init_temp().await;
695 create_sprint(dir.path(), &sid("S-1"), "Sprint 1", None, None)
696 .await
697 .unwrap();
698 for title in ["First", "Second", "Third"] {
699 add_item(dir.path(), title, NewItem::default())
700 .await
701 .unwrap();
702 }
703 assign_sprint(dir.path(), &sid("S-1"), &ItemId::new("T", 1))
704 .await
705 .unwrap();
706 move_item(dir.path(), &ItemId::new("T", 3), "in-progress")
707 .await
708 .unwrap();
709
710 let assigned = assign_sprint_by_status(dir.path(), &sid("S-1"), "todo", None)
711 .await
712 .expect("bulk assignment succeeds");
713
714 assert_eq!(
715 assigned
716 .iter()
717 .map(|item| item.id.to_string())
718 .collect::<Vec<_>>(),
719 ["T-2"]
720 );
721 }
722
723 #[tokio::test]
724 async fn bulk_assignment_limit_does_not_count_target_members() {
725 let dir = init_temp().await;
726 create_sprint(dir.path(), &sid("S-1"), "Sprint 1", None, None)
727 .await
728 .unwrap();
729 for title in ["Already assigned", "Next in rank"] {
730 add_item(dir.path(), title, NewItem::default())
731 .await
732 .unwrap();
733 }
734 assign_sprint(dir.path(), &sid("S-1"), &ItemId::new("T", 1))
735 .await
736 .unwrap();
737
738 let assigned = assign_sprint_by_status(dir.path(), &sid("S-1"), "todo", Some(1))
739 .await
740 .expect("bulk assignment succeeds");
741
742 assert_eq!(
743 assigned
744 .iter()
745 .map(|item| item.id.to_string())
746 .collect::<Vec<_>>(),
747 ["T-2"]
748 );
749 }
750
751 #[tokio::test]
752 async fn bulk_assignment_rejects_other_sprint_without_partial_assignment() {
753 let dir = init_temp().await;
754 create_sprint(dir.path(), &sid("S-1"), "Sprint 1", None, None)
755 .await
756 .unwrap();
757 create_sprint(dir.path(), &sid("S-2"), "Sprint 2", None, None)
758 .await
759 .unwrap();
760 for title in ["Already assigned", "Still todo"] {
761 add_item(dir.path(), title, NewItem::default())
762 .await
763 .unwrap();
764 }
765 assign_sprint(dir.path(), &sid("S-2"), &ItemId::new("T", 1))
766 .await
767 .unwrap();
768
769 let err = assign_sprint_by_status(dir.path(), &sid("S-1"), "todo", None)
770 .await
771 .unwrap_err();
772 assert!(
773 matches!(&err, Error::InvalidFilterOption(message) if message.contains("T-1") && message.contains("S-2")),
774 "got {err:?}"
775 );
776
777 let repo = FileRepository::new(dir.path().join(".pinto"));
778 assert_eq!(
779 BacklogItemRepository::load(&repo, &ItemId::new("T", 2))
780 .await
781 .unwrap()
782 .sprint,
783 None
784 );
785 }
786
787 #[tokio::test]
788 async fn bulk_assignment_rejects_unknown_status_and_zero_limit_without_changes() {
789 let dir = init_temp().await;
790 create_sprint(dir.path(), &sid("S-1"), "Sprint 1", None, None)
791 .await
792 .unwrap();
793 let item = add_item(dir.path(), "Task", NewItem::default())
794 .await
795 .unwrap();
796
797 assert_eq!(
798 assign_sprint_by_status(dir.path(), &sid("S-1"), "missing", None)
799 .await
800 .unwrap_err(),
801 Error::UnknownStatus("missing".to_string())
802 );
803 assert!(matches!(
804 assign_sprint_by_status(dir.path(), &sid("S-1"), "todo", Some(0))
805 .await
806 .unwrap_err(),
807 Error::InvalidFilterOption(message) if message.contains("limit")
808 ));
809
810 let repo = FileRepository::new(dir.path().join(".pinto"));
811 assert_eq!(
812 BacklogItemRepository::load(&repo, &item.id)
813 .await
814 .unwrap()
815 .sprint,
816 None
817 );
818 }
819
820 #[tokio::test]
821 async fn bulk_assignment_rejects_missing_or_closed_sprint_without_changes() {
822 let dir = init_temp().await;
823 let item = add_item(dir.path(), "Task", NewItem::default())
824 .await
825 .unwrap();
826
827 assert_eq!(
828 assign_sprint_by_status(dir.path(), &sid("S-9"), "todo", None)
829 .await
830 .unwrap_err(),
831 Error::SprintNotFound(sid("S-9"))
832 );
833
834 create_sprint(
835 dir.path(),
836 &sid("S-1"),
837 "Sprint 1",
838 Some("Ship it".to_string()),
839 None,
840 )
841 .await
842 .unwrap();
843 start_sprint(dir.path(), &sid("S-1")).await.unwrap();
844 close_sprint(dir.path(), &sid("S-1")).await.unwrap();
845 assert_eq!(
846 assign_sprint_by_status(dir.path(), &sid("S-1"), "todo", None)
847 .await
848 .unwrap_err(),
849 Error::SprintClosed(sid("S-1"))
850 );
851
852 let repo = FileRepository::new(dir.path().join(".pinto"));
853 assert_eq!(
854 BacklogItemRepository::load(&repo, &item.id)
855 .await
856 .unwrap()
857 .sprint,
858 None
859 );
860 }
861
862 #[tokio::test]
863 async fn unassign_clears_item_sprint() {
864 let dir = init_temp().await;
865 create_sprint(dir.path(), &sid("S-1"), "Sprint 1", None, None)
866 .await
867 .unwrap();
868 let item = add_item(dir.path(), "Task", NewItem::default())
869 .await
870 .unwrap();
871 assign_sprint(dir.path(), &sid("S-1"), &item.id)
872 .await
873 .unwrap();
874
875 let cleared = unassign_sprint(dir.path(), &sid("S-1"), &item.id)
876 .await
877 .expect("unassign succeeds");
878 assert_eq!(cleared.sprint, None);
879 }
880
881 #[tokio::test]
882 async fn unassign_item_not_in_sprint_returns_error() {
883 let dir = init_temp().await;
884 create_sprint(dir.path(), &sid("S-1"), "Sprint 1", None, None)
885 .await
886 .unwrap();
887 let item = add_item(dir.path(), "Task", NewItem::default())
888 .await
889 .unwrap();
890
891 let err = unassign_sprint(dir.path(), &sid("S-1"), &item.id)
892 .await
893 .unwrap_err();
894 assert_eq!(
895 err,
896 Error::NotInSprint {
897 item: item.id,
898 sprint: sid("S-1"),
899 }
900 );
901 }
902
903 #[tokio::test]
904 async fn list_returns_sprints_in_creation_order() {
905 let dir = init_temp().await;
906 create_sprint(dir.path(), &sid("S-1"), "First", None, None)
907 .await
908 .unwrap();
909 create_sprint(dir.path(), &sid("S-2"), "Second", None, None)
910 .await
911 .unwrap();
912
913 let ids: Vec<String> = list_sprints(dir.path())
914 .await
915 .expect("list succeeds")
916 .into_iter()
917 .map(|s| s.id.as_str().to_string())
918 .collect();
919 assert_eq!(ids, ["S-1", "S-2"]);
920 }
921
922 #[tokio::test]
923 async fn list_on_empty_board_is_empty() {
924 let dir = init_temp().await;
925 assert!(list_sprints(dir.path()).await.unwrap().is_empty());
926 }
927}