Skip to main content

pinto/service/
sprint.rs

1//! Sprint creation, state transition, and PBI assignment services.
2//!
3//! Combines the [`crate::sprint::Sprint`] and [`crate::backlog::BacklogItem`] domain types with the
4//! persistence layer. A backlog item's sprint assignment is stored as the sprint ID string in
5//! `BacklogItem::sprint`.
6
7use 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
15/// Create a sprint on the board in `project_dir` and return the saved [`Sprint`].
16///
17/// The state is [`crate::sprint::SprintState::Planned`]. `goal` is persisted after the frontmatter
18/// as the sprint Markdown body.
19/// When `period` is provided, retain the planned start and end dates. Return
20/// [`Error::InvalidSprintPeriod`] when the start is after the end, [`Error::SprintExists`] when
21/// the ID is already used, [`Error::NotInitialized`] for an uninitialized board, or
22/// [`Error::EmptySprintTitle`] for an empty title.
23pub 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    // Reject an inverted period because the burndown time axis would be invalid.
31    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    // Check for an existing ID before saving so creation never overwrites a sprint.
43    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
62/// Update the title, goal, and/or planned period of an existing sprint.
63///
64/// Fields set to `None` remain unchanged. Return [`Error::NothingToUpdate`] when no field is
65/// supplied, [`Error::EmptySprintTitle`] for a blank title, [`Error::InvalidSprintPeriod`] for an
66/// inverted period, or [`Error::SprintNotFound`] when the sprint does not exist.
67pub 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
82/// Delete a sprint and clear its assignment from every PBI that references it.
83///
84/// The PBIs remain in the backlog. All reads and writes happen while the board lock is held, and
85/// Git-backed boards commit the sprint deletion and assignment changes as one service operation.
86pub 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
107/// Start a sprint (`planned` → `active`). Returns the saved [`Sprint`].
108///
109/// Return [`Error::NotInitialized`] when the board is uninitialized or
110/// [`Error::SprintNotFound`] when no sprint with `id` exists. Starting from anything other than
111/// `planned` returns [`Error::InvalidSprintTransition`].
112pub async fn start_sprint(project_dir: &Path, id: &SprintId) -> Result<Sprint> {
113    transition_sprint(project_dir, id, Sprint::start).await
114}
115
116/// Close the sprint (`active` → `closed`). Returns the saved [`Sprint`].
117///
118/// Return [`Error::NotInitialized`] when the board is uninitialized or
119/// [`Error::SprintNotFound`] when no sprint with `id` exists. Closing from anything other than
120/// `active` returns [`Error::InvalidSprintTransition`].
121pub async fn close_sprint(project_dir: &Path, id: &SprintId) -> Result<Sprint> {
122    transition_sprint(project_dir, id, Sprint::close).await
123}
124
125/// Load the sprint, apply `transition` ([`Sprint::start`] / [`Sprint::close`]) and save.
126///
127/// The domain layer validates the transition before the updated sprint is saved, so failures leave
128/// the on-disk state unchanged.
129async 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
142/// Validate a raw sprint assignment while the caller holds the board write lock.
143pub(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
152/// Assign PBI `item_id` to sprint `sprint_id` and return the saved [`BacklogItem`].
153///
154/// Validate that the sprint exists and is not closed before assigning, preventing dangling or
155/// semantically invalid assignments.
156/// Return [`Error::NotInitialized`] when the board is uninitialized, [`Error::SprintNotFound`]
157/// when the sprint does not exist, [`Error::SprintClosed`] when it is closed, or
158/// [`Error::NotFound`] when the PBI does not exist.
159pub 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
167/// Assign a PBI from a raw CLI sprint ID, validating its grammar and existence before saving.
168pub 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
183/// Assign matching PBIs to a sprint in backlog rank order.
184///
185/// `status` must be a configured workflow column. When `limit` is `Some`, only the first `limit`
186/// matching PBIs are considered; omitting it considers every matching PBI. Items already assigned
187/// to the target sprint are skipped without consuming the limit. An item assigned to another
188/// sprint causes the operation to fail before any item is saved, so validation errors do not leave
189/// a partially assigned set.
190pub 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    // BacklogItemRepository::list returns canonical rank order. Exclude target-sprint members
209    // before applying the limit so rerunning a command fills the requested number of new slots.
210    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    // Validate every selected assignment before the first save. This makes conflicts with another
221    // sprint all-or-nothing from the user's perspective.
222    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
258/// Restore the original item contents after a failed multi-item persistence operation.
259async 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
274/// Remove PBI `item_id` from sprint `sprint_id` and return the saved [`BacklogItem`].
275///
276/// Return [`Error::NotInSprint`] when the item is assigned to another sprint or none. Return
277/// [`Error::NotInitialized`] for an uninitialized board or [`Error::NotFound`] when the item does
278/// not exist.
279pub 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
299/// Return sprints from `project_dir` in ascending creation-time order.
300///
301/// [`Error::NotInitialized`] if the board is uninitialized.
302pub 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
307/// Update sprint capacity settings and return the calculated capacity.
308pub 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
327/// Return the configured capacity for a sprint.
328pub 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    /// Create a date and time at midnight UTC (for planned schedule tests).
388    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        // It is made permanent.
407        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        // Existing files will not be overwritten.
440        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        // It is made permanent.
477        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        // Not assigned.
618        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}