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, 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/// The source of a non-blocking Sprint load warning.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum SprintLoadWarningKind {
21    /// The assigned point total is above the configured capacity-hours threshold.
22    Capacity,
23    /// The assigned point total is above the historical velocity threshold.
24    Velocity,
25}
26
27impl SprintLoadWarningKind {
28    /// Return the short label used in localized CLI warning messages.
29    #[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    /// Return the unit attached to the numeric threshold in CLI output.
38    #[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/// A non-blocking warning produced when a Sprint's assigned points exceed a threshold.
48#[derive(Debug, Clone, PartialEq)]
49pub struct SprintLoadWarning {
50    /// Which planning comparison was exceeded.
51    pub kind: SprintLoadWarningKind,
52    /// Sum of estimated points assigned to the Sprint.
53    pub points: u32,
54    /// Numeric threshold that the assigned points exceeded.
55    pub threshold: f64,
56}
57
58/// Calculate the current load warnings for a Sprint without changing board data.
59pub 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
75/// Calculate Sprint load warnings from an already loaded board snapshot.
76fn 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
111/// Return the average completed points from the target's five most recent closed predecessors.
112fn 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
129/// Create a sprint on the board in `project_dir` and return the saved [`Sprint`].
130///
131/// The state is [`crate::sprint::SprintState::Planned`]. `goal` is persisted after the frontmatter
132/// as the sprint Markdown body.
133/// When `period` is provided, retain the planned start and end dates. Return
134/// [`Error::InvalidSprintPeriod`] when the start is after the end, [`Error::SprintExists`] when
135/// the ID is already used, [`Error::NotInitialized`] for an uninitialized board, or
136/// [`Error::EmptySprintTitle`] for an empty title.
137pub 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    // Reject an inverted period because the burndown time axis would be invalid.
145    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    // Check for an existing ID before saving so creation never overwrites a sprint.
157    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
176/// Update the title, goal, and/or planned period of an existing sprint.
177///
178/// Fields set to `None` remain unchanged. Return [`Error::NothingToUpdate`] when no field is
179/// supplied, [`Error::EmptySprintTitle`] for a blank title, [`Error::InvalidSprintPeriod`] for an
180/// inverted period, or [`Error::SprintNotFound`] when the sprint does not exist.
181pub 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
196/// Delete a sprint and clear its assignment from every PBI that references it.
197///
198/// The PBIs remain in the backlog. All reads and writes happen while the board lock is held, and
199/// Git-backed boards commit the sprint deletion and assignment changes as one service operation.
200pub 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
221/// Start a sprint (`planned` → `active`). Returns the saved [`Sprint`].
222///
223/// Return [`Error::NotInitialized`] when the board is uninitialized or
224/// [`Error::SprintNotFound`] when no sprint with `id` exists. Starting from anything other than
225/// `planned` returns [`Error::InvalidSprintTransition`].
226pub async fn start_sprint(project_dir: &Path, id: &SprintId) -> Result<Sprint> {
227    transition_sprint(project_dir, id, Sprint::start).await
228}
229
230/// How unfinished PBIs are handled when their sprint closes.
231#[derive(Debug, Default, Clone, PartialEq, Eq)]
232pub enum SprintCloseAction {
233    /// Keep unfinished PBIs assigned to the closed sprint.
234    #[default]
235    Retain,
236    /// Reassign unfinished PBIs to a planned or active sprint.
237    Rollover(SprintId),
238    /// Clear the sprint assignment from unfinished PBIs.
239    Release,
240}
241
242/// Close the sprint (`active` → `closed`) and return the saved [`Sprint`].
243///
244/// A rollover target is validated before the first write. Only unfinished PBIs are reassigned or
245/// released; completed PBIs remain byte-for-byte equivalent at the domain level. The sprint stores
246/// the actual close time and a snapshot of unfinished estimated points and item counts for
247/// retrospective display, separate from velocity.
248pub 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    // Match the repository-wide Git failure contract: once durable files are saved, a commit
320    // failure leaves them available for inspection and manual recovery.
321    repo.commit(&format!("pinto: update {}", sprint.id)).await?;
322    Ok(sprint)
323}
324
325/// Load the sprint, apply a state transition, and save.
326///
327/// The domain layer validates the transition before the updated sprint is saved, so failures leave
328/// the on-disk state unchanged.
329async 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
342/// Restore sprint and unfinished-PBI contents after a failed close persistence operation.
343async 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
364/// Validate a raw sprint assignment while the caller holds the board write lock.
365pub(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
374/// Assign PBI `item_id` to sprint `sprint_id` and return the saved [`BacklogItem`].
375///
376/// Validate that the sprint exists and is not closed before assigning, preventing dangling or
377/// semantically invalid assignments.
378/// Return [`Error::NotInitialized`] when the board is uninitialized, [`Error::SprintNotFound`]
379/// when the sprint does not exist, [`Error::SprintClosed`] when it is closed, or
380/// [`Error::NotFound`] when the PBI does not exist.
381pub 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
389/// Assign a PBI from a raw CLI sprint ID, validating its grammar and existence before saving.
390pub 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
405/// Assign matching PBIs to a sprint in backlog rank order.
406///
407/// `status` must be a configured workflow column. When `limit` is `Some`, only the first `limit`
408/// matching PBIs are considered; omitting it considers every matching PBI. Items already assigned
409/// to the target sprint are skipped without consuming the limit. An item assigned to another
410/// sprint causes the operation to fail before any item is saved, so validation errors do not leave
411/// a partially assigned set.
412pub 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    // BacklogItemRepository::list returns canonical rank order. Exclude target-sprint members
431    // before applying the limit so rerunning a command fills the requested number of new slots.
432    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    // Validate every selected assignment before the first save. This makes conflicts with another
443    // sprint all-or-nothing from the user's perspective.
444    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
480/// Restore the original item contents after a failed multi-item persistence operation.
481async 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
496/// Remove PBI `item_id` from sprint `sprint_id` and return the saved [`BacklogItem`].
497///
498/// Return [`Error::NotInSprint`] when the item is assigned to another sprint or none. Return
499/// [`Error::NotInitialized`] for an uninitialized board or [`Error::NotFound`] when the item does
500/// not exist.
501pub 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
521/// Return sprints from `project_dir` in ascending creation-time order.
522///
523/// [`Error::NotInitialized`] if the board is uninitialized.
524pub 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
529/// Update sprint capacity settings and return the calculated capacity.
530pub 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
549/// Return the configured capacity for a sprint.
550pub 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    /// Create a date and time at midnight UTC (for planned schedule tests).
612    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        // It is made permanent.
631        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        // Existing files will not be overwritten.
664        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        // It is made permanent.
701        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        // Not assigned.
1017        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}