Skip to main content

pinto/
sprint.rs

1//! Sprint model.
2//!
3//! Pure logic for sprint IDs, goals, schedules, capacity, and state transitions.
4//! The model has no I/O dependency; [`crate::storage`] persists it and callers supply timestamps.
5
6use crate::error::{Error, Result};
7use chrono::{DateTime, Utc};
8use std::fmt;
9use std::str::FromStr;
10
11/// Stable identifier for the sprint.
12///
13/// It is also used in file names (`<id>.md`), so it accepts only path-safe ASCII slugs containing
14/// alphanumerics, `-`, or `_`. A PBI stores its assignment as this ID string.
15#[derive(Debug, Clone, PartialEq, Eq, Hash)]
16pub struct SprintId(String);
17
18impl SprintId {
19    /// Validate a slug and create a sprint ID.
20    ///
21    /// Return [`Error::InvalidSprintId`] when the value is empty or contains anything other than
22    /// ASCII alphanumerics, `-`, or `_`.
23    pub fn new(value: impl Into<String>) -> Result<Self> {
24        let value = value.into();
25        let valid = !value.is_empty()
26            && value
27                .bytes()
28                .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
29        if !valid {
30            return Err(Error::InvalidSprintId(value));
31        }
32        Ok(Self(value))
33    }
34
35    /// Return the ID string.
36    #[must_use]
37    pub fn as_str(&self) -> &str {
38        &self.0
39    }
40}
41
42impl fmt::Display for SprintId {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        f.write_str(&self.0)
45    }
46}
47
48impl FromStr for SprintId {
49    type Err = Error;
50
51    fn from_str(s: &str) -> Result<Self> {
52        SprintId::new(s)
53    }
54}
55
56/// Sprint state. Transitions are allowed only from `planned` to `active` to `closed`.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum SprintState {
59    /// Planned and not yet started; the default state after creation.
60    Planned,
61    /// In progress.
62    Active,
63    /// Finished and closed.
64    Closed,
65}
66
67impl SprintState {
68    /// Return the lowercase string used for persistence.
69    #[must_use]
70    pub fn as_str(&self) -> &'static str {
71        match self {
72            SprintState::Planned => "planned",
73            SprintState::Active => "active",
74            SprintState::Closed => "closed",
75        }
76    }
77}
78
79impl fmt::Display for SprintState {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        f.write_str(self.as_str())
82    }
83}
84
85impl FromStr for SprintState {
86    type Err = Error;
87
88    fn from_str(s: &str) -> Result<Self> {
89        match s {
90            "planned" => Ok(SprintState::Planned),
91            "active" => Ok(SprintState::Active),
92            "closed" => Ok(SprintState::Closed),
93            other => Err(Error::InvalidSprintState(other.to_string())),
94        }
95    }
96}
97
98/// Unfinished work captured when a sprint is closed.
99///
100/// This is retrospective context only. It is deliberately separate from completed points so
101/// velocity remains the amount of work that reached Done during the sprint.
102#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
103pub struct SprintSpillover {
104    /// Estimated points on unfinished PBIs.
105    pub points: u32,
106    /// Number of unfinished PBIs, including unestimated work.
107    pub items: u32,
108    /// Number of unfinished PBIs without an estimate.
109    pub unestimated_items: u32,
110}
111
112/// A sprint that groups PBIs into a time-boxed work period.
113///
114/// `start` and `end` are planned dates independent of state transitions; editing sets the schedule
115/// while [`Sprint::start`] and [`Sprint::close`] advance the state.
116#[derive(Debug, Clone, PartialEq)]
117pub struct Sprint {
118    /// Stable identifier.
119    pub id: SprintId,
120    /// Display title.
121    pub title: String,
122    /// Sprint goal (free description, multiple lines allowed).
123    pub goal: String,
124    /// Planned start date and time, or `None` when unset.
125    pub start: Option<DateTime<Utc>>,
126    /// Planned end date and time, or `None` when unset.
127    pub end: Option<DateTime<Utc>>,
128    /// Working hours per day; capacity is unavailable when unset.
129    pub daily_work_hours: Option<f64>,
130    /// Holidays within the period, deducted from the inclusive calendar-day count.
131    pub holiday_days: Option<u32>,
132    /// Fraction of daily capacity retained after meetings and interruptions (`0.0..=1.0`).
133    pub deduction_factor: Option<f64>,
134    /// Snapshot of unfinished work when this sprint was closed.
135    pub spillover: SprintSpillover,
136    /// Current state (`planned`, `active`, or `closed`).
137    pub state: SprintState,
138    /// Actual close timestamp, used to keep closed-sprint velocity immutable.
139    pub closed_at: Option<DateTime<Utc>>,
140    pub created: DateTime<Utc>,
141    pub updated: DateTime<Utc>,
142}
143
144impl Sprint {
145    /// Create a minimal sprint in [`SprintState::Planned`] with an empty goal and no schedule.
146    ///
147    /// Return [`Error::EmptySprintTitle`] when `title` is empty or only whitespace.
148    pub fn new(id: SprintId, title: impl Into<String>, now: DateTime<Utc>) -> Result<Self> {
149        let title = title.into();
150        if title.trim().is_empty() {
151            return Err(Error::EmptySprintTitle);
152        }
153        Ok(Self {
154            id,
155            title,
156            goal: String::new(),
157            start: None,
158            end: None,
159            daily_work_hours: None,
160            holiday_days: None,
161            deduction_factor: None,
162            spillover: SprintSpillover::default(),
163            state: SprintState::Planned,
164            closed_at: None,
165            created: now,
166            updated: now,
167        })
168    }
169
170    /// Update the editable sprint details and refresh `updated`.
171    ///
172    /// Fields set to `None` remain unchanged. A blank title or inverted period is rejected before
173    /// any field changes are applied. Return [`Error::NothingToUpdate`] when no field is supplied.
174    pub fn update_details(
175        &mut self,
176        title: Option<String>,
177        goal: Option<String>,
178        period: Option<(DateTime<Utc>, DateTime<Utc>)>,
179        now: DateTime<Utc>,
180    ) -> Result<()> {
181        if title.is_none() && goal.is_none() && period.is_none() {
182            return Err(Error::NothingToUpdate);
183        }
184        if let Some(title) = &title
185            && title.trim().is_empty()
186        {
187            return Err(Error::EmptySprintTitle);
188        }
189        if let Some((start, end)) = period
190            && start > end
191        {
192            return Err(Error::InvalidSprintPeriod {
193                start: start.date_naive(),
194                end: end.date_naive(),
195            });
196        }
197
198        if let Some(title) = title {
199            self.title = title;
200        }
201        if let Some(goal) = goal {
202            self.goal = goal;
203        }
204        if let Some((start, end)) = period {
205            self.start = Some(start);
206            self.end = Some(end);
207        }
208        self.updated = now;
209        Ok(())
210    }
211
212    /// Start a sprint (`planned` → `active`) and update `updated`.
213    ///
214    /// A sprint without a non-blank goal is rejected with [`Error::EmptySprintGoal`].
215    /// Return [`Error::InvalidSprintTransition`] for any other starting state without changing it.
216    pub fn start(&mut self, now: DateTime<Utc>) -> Result<()> {
217        if self.state == SprintState::Planned && self.goal.trim().is_empty() {
218            return Err(Error::EmptySprintGoal);
219        }
220        self.transition(SprintState::Active, now)
221    }
222
223    /// Close a sprint (`active` → `closed`), recording the actual time and spillover snapshot.
224    ///
225    /// Return [`Error::InvalidSprintTransition`] for any other state without changing it.
226    pub fn close(&mut self, now: DateTime<Utc>, spillover: SprintSpillover) -> Result<()> {
227        self.transition(SprintState::Closed, now)?;
228        self.closed_at = Some(now);
229        self.spillover = spillover;
230        Ok(())
231    }
232
233    /// Update settings for capacity calculations.
234    ///
235    /// Count calendar days inclusively, subtract `holiday_days`, and multiply the result by
236    /// `daily_work_hours × deduction_factor`. Reject an unset period or invalid value without
237    /// changing existing settings.
238    pub fn set_capacity(
239        &mut self,
240        daily_work_hours: f64,
241        holiday_days: u32,
242        deduction_factor: f64,
243    ) -> Result<()> {
244        if !daily_work_hours.is_finite() || daily_work_hours < 0.0 {
245            return Err(Error::InvalidDailyWorkHours(daily_work_hours.to_string()));
246        }
247        if !deduction_factor.is_finite() || !(0.0..=1.0).contains(&deduction_factor) {
248            return Err(Error::InvalidDeductionFactor(deduction_factor.to_string()));
249        }
250        let calendar_days = self.calendar_days()?;
251        if holiday_days > calendar_days {
252            return Err(Error::InvalidSprintHolidays {
253                holidays: holiday_days,
254                calendar_days,
255            });
256        }
257        self.daily_work_hours = Some(daily_work_hours);
258        self.holiday_days = Some(holiday_days);
259        self.deduction_factor = Some(deduction_factor);
260        Ok(())
261    }
262
263    /// Return working days and available hours when the schedule and capacity settings are complete.
264    #[must_use]
265    pub fn capacity(&self) -> Option<SprintCapacity> {
266        let calendar_days = self.calendar_days().ok()?;
267        let (daily_work_hours, holiday_days, deduction_factor) = (
268            self.daily_work_hours?,
269            self.holiday_days?,
270            self.deduction_factor?,
271        );
272        let working_days = calendar_days.checked_sub(holiday_days)?;
273        Some(SprintCapacity {
274            working_days,
275            hours: f64::from(working_days) * daily_work_hours * deduction_factor,
276        })
277    }
278
279    fn calendar_days(&self) -> Result<u32> {
280        let (start, end) = self
281            .start
282            .zip(self.end)
283            .ok_or_else(|| Error::SprintCapacityPeriodUnset(self.id.clone()))?;
284        if start > end {
285            return Err(Error::InvalidSprintPeriod {
286                start: start.date_naive(),
287                end: end.date_naive(),
288            });
289        }
290        let days = (end.date_naive() - start.date_naive()).num_days() + 1;
291        u32::try_from(days).map_err(|_| Error::InvalidSprintPeriod {
292            start: start.date_naive(),
293            end: end.date_naive(),
294        })
295    }
296
297    /// Apply a forward-only state transition: `planned → active → closed`.
298    fn transition(&mut self, to: SprintState, now: DateTime<Utc>) -> Result<()> {
299        let allowed = matches!(
300            (self.state, to),
301            (SprintState::Planned, SprintState::Active)
302                | (SprintState::Active, SprintState::Closed)
303        );
304        if !allowed {
305            return Err(Error::InvalidSprintTransition {
306                from: self.state,
307                to,
308            });
309        }
310        self.state = to;
311        self.updated = now;
312        Ok(())
313    }
314}
315
316/// Sprint capacity calculation results.
317#[derive(Debug, Clone, PartialEq)]
318pub struct SprintCapacity {
319    /// Inclusive calendar days minus holidays.
320    pub working_days: u32,
321    /// Available working hours after deductions.
322    pub hours: f64,
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use chrono::{DateTime, Utc};
329
330    fn epoch() -> DateTime<Utc> {
331        DateTime::from_timestamp(0, 0).expect("valid epoch")
332    }
333
334    fn sid(s: &str) -> SprintId {
335        SprintId::new(s).expect("valid sprint id")
336    }
337
338    // --- SprintId ---
339
340    #[test]
341    fn sprint_id_accepts_safe_slugs() {
342        for ok in ["S-1", "sprint_1", "2026Q3", "A", "release-1-x"] {
343            assert!(SprintId::new(ok).is_ok(), "expected {ok:?} to be accepted");
344        }
345    }
346
347    #[test]
348    fn sprint_id_rejects_unsafe_slugs() {
349        for bad in ["", "S 1", "a/b", "a.b", "エス", "with\tspace"] {
350            assert!(
351                SprintId::new(bad).is_err(),
352                "expected {bad:?} to be rejected"
353            );
354        }
355    }
356
357    #[test]
358    fn sprint_id_display_and_parse_roundtrip() {
359        let id = sid("S-1");
360        assert_eq!(id.to_string(), "S-1");
361        assert_eq!("S-1".parse::<SprintId>().unwrap(), id);
362        assert_eq!(id.as_str(), "S-1");
363    }
364
365    // --- SprintState ---
366
367    #[test]
368    fn sprint_state_string_roundtrip() {
369        for (state, text) in [
370            (SprintState::Planned, "planned"),
371            (SprintState::Active, "active"),
372            (SprintState::Closed, "closed"),
373        ] {
374            assert_eq!(state.as_str(), text);
375            assert_eq!(state.to_string(), text);
376            assert_eq!(text.parse::<SprintState>().unwrap(), state);
377        }
378    }
379
380    #[test]
381    fn sprint_state_rejects_unknown() {
382        let err = "archived".parse::<SprintState>().unwrap_err();
383        assert_eq!(err, Error::InvalidSprintState("archived".to_string()));
384    }
385
386    // --- Sprint construction ---
387
388    #[test]
389    fn new_sprint_uses_planned_defaults() {
390        let s = Sprint::new(sid("S-1"), "Sprint 1", epoch()).unwrap();
391        assert_eq!(s.id, sid("S-1"));
392        assert_eq!(s.title, "Sprint 1");
393        assert_eq!(s.goal, "");
394        assert_eq!(s.start, None);
395        assert_eq!(s.end, None);
396        assert_eq!(s.daily_work_hours, None);
397        assert_eq!(s.holiday_days, None);
398        assert_eq!(s.deduction_factor, None);
399        assert_eq!(s.spillover, SprintSpillover::default());
400        assert_eq!(s.capacity(), None);
401        assert_eq!(s.state, SprintState::Planned);
402        assert_eq!(s.closed_at, None);
403        assert_eq!(s.created, epoch());
404        assert_eq!(s.updated, epoch());
405    }
406
407    #[test]
408    fn new_sprint_rejects_empty_title() {
409        let err = Sprint::new(sid("S-1"), "   ", epoch()).unwrap_err();
410        assert_eq!(err, Error::EmptySprintTitle);
411    }
412
413    #[test]
414    fn update_details_changes_title_goal_period_and_timestamp() {
415        let mut sprint = Sprint::new(sid("S-1"), "Planning", epoch()).unwrap();
416        let start = epoch() + chrono::Duration::days(1);
417        let end = epoch() + chrono::Duration::days(5);
418        let updated = epoch() + chrono::Duration::days(10);
419
420        sprint
421            .update_details(
422                Some("Execution".to_string()),
423                Some("Ship the sprint".to_string()),
424                Some((start, end)),
425                updated,
426            )
427            .unwrap();
428
429        assert_eq!(sprint.title, "Execution");
430        assert_eq!(sprint.goal, "Ship the sprint");
431        assert_eq!(sprint.start, Some(start));
432        assert_eq!(sprint.end, Some(end));
433        assert_eq!(sprint.updated, updated);
434        assert_eq!(sprint.created, epoch());
435    }
436
437    #[test]
438    fn update_details_rejects_invalid_values_without_mutating() {
439        let mut sprint = Sprint::new(sid("S-1"), "Planning", epoch()).unwrap();
440        let original = sprint.clone();
441        let later = epoch() + chrono::Duration::days(1);
442
443        assert_eq!(
444            sprint.update_details(Some("   ".to_string()), None, None, later),
445            Err(Error::EmptySprintTitle)
446        );
447        assert_eq!(sprint, original);
448
449        let start = epoch() + chrono::Duration::days(5);
450        let end = epoch() + chrono::Duration::days(1);
451        assert_eq!(
452            sprint.update_details(None, None, Some((start, end)), later),
453            Err(Error::InvalidSprintPeriod {
454                start: start.date_naive(),
455                end: end.date_naive(),
456            })
457        );
458        assert_eq!(sprint, original);
459
460        assert_eq!(
461            sprint.update_details(None, None, None, later),
462            Err(Error::NothingToUpdate)
463        );
464        assert_eq!(sprint, original);
465    }
466
467    #[test]
468    fn capacity_uses_inclusive_calendar_days_and_deductions() {
469        let mut sprint = Sprint::new(sid("S-1"), "Sprint 1", epoch()).unwrap();
470        sprint.start = Some(
471            chrono::NaiveDate::from_ymd_opt(2026, 7, 6)
472                .unwrap()
473                .and_hms_opt(0, 0, 0)
474                .unwrap()
475                .and_utc(),
476        );
477        sprint.end = Some(
478            chrono::NaiveDate::from_ymd_opt(2026, 7, 10)
479                .unwrap()
480                .and_hms_opt(0, 0, 0)
481                .unwrap()
482                .and_utc(),
483        );
484
485        sprint.set_capacity(8.0, 1, 0.8).unwrap();
486
487        assert_eq!(
488            sprint.capacity(),
489            Some(SprintCapacity {
490                working_days: 4,
491                hours: 25.6,
492            })
493        );
494    }
495
496    #[test]
497    fn capacity_rejects_invalid_values_and_holidays_outside_period() {
498        let mut sprint = Sprint::new(sid("S-1"), "Sprint 1", epoch()).unwrap();
499        sprint.start = Some(epoch());
500        sprint.end = Some(epoch());
501
502        assert_eq!(
503            sprint.set_capacity(-1.0, 0, 1.0),
504            Err(Error::InvalidDailyWorkHours("-1".to_string()))
505        );
506        assert_eq!(
507            sprint.set_capacity(8.0, 0, 1.1),
508            Err(Error::InvalidDeductionFactor("1.1".to_string()))
509        );
510        assert_eq!(
511            sprint.set_capacity(8.0, 2, 1.0),
512            Err(Error::InvalidSprintHolidays {
513                holidays: 2,
514                calendar_days: 1,
515            })
516        );
517    }
518
519    // --- transitions ---
520
521    #[test]
522    fn start_moves_planned_to_active_and_updates_timestamp() {
523        let mut s = Sprint::new(sid("S-1"), "Sprint 1", epoch()).unwrap();
524        s.goal = "Ship the sprint".to_string();
525        let later = epoch() + chrono::Duration::seconds(60);
526
527        s.start(later).unwrap();
528
529        assert_eq!(s.state, SprintState::Active);
530        assert_eq!(s.updated, later);
531        assert_eq!(s.created, epoch(), "created must not change");
532    }
533
534    #[test]
535    fn start_requires_a_non_empty_goal() {
536        let mut s = Sprint::new(sid("S-1"), "Sprint 1", epoch()).unwrap();
537
538        let err = s.start(epoch()).unwrap_err();
539
540        assert_eq!(err, Error::EmptySprintGoal);
541        assert_eq!(s.state, SprintState::Planned);
542    }
543
544    #[test]
545    fn close_moves_active_to_closed() {
546        let mut s = Sprint::new(sid("S-1"), "Sprint 1", epoch()).unwrap();
547        s.goal = "Ship the sprint".to_string();
548        s.start(epoch()).unwrap();
549        let later = epoch() + chrono::Duration::seconds(120);
550
551        let spillover = SprintSpillover {
552            points: 5,
553            items: 2,
554            unestimated_items: 1,
555        };
556        s.close(later, spillover).unwrap();
557
558        assert_eq!(s.state, SprintState::Closed);
559        assert_eq!(s.updated, later);
560        assert_eq!(s.closed_at, Some(later));
561        assert_eq!(s.spillover, spillover);
562    }
563
564    #[test]
565    fn start_from_non_planned_is_rejected_and_leaves_state() {
566        let mut s = Sprint::new(sid("S-1"), "Sprint 1", epoch()).unwrap();
567        s.goal = "Ship the sprint".to_string();
568        s.start(epoch()).unwrap(); // active
569
570        let err = s.start(epoch()).unwrap_err();
571        assert_eq!(
572            err,
573            Error::InvalidSprintTransition {
574                from: SprintState::Active,
575                to: SprintState::Active,
576            }
577        );
578        assert_eq!(s.state, SprintState::Active, "state unchanged on failure");
579    }
580
581    #[test]
582    fn close_from_planned_is_rejected() {
583        let mut s = Sprint::new(sid("S-1"), "Sprint 1", epoch()).unwrap();
584        let original = s.clone();
585        let err = s
586            .close(
587                epoch(),
588                SprintSpillover {
589                    points: 5,
590                    items: 1,
591                    unestimated_items: 0,
592                },
593            )
594            .unwrap_err();
595        assert_eq!(
596            err,
597            Error::InvalidSprintTransition {
598                from: SprintState::Planned,
599                to: SprintState::Closed,
600            }
601        );
602        assert_eq!(s, original, "failed close does not store spillover");
603    }
604}