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/// A sprint that groups PBIs into a time-boxed work period.
99///
100/// `start` and `end` are planned dates independent of state transitions; editing sets the schedule
101/// while [`Sprint::start`] and [`Sprint::close`] advance the state.
102#[derive(Debug, Clone, PartialEq)]
103pub struct Sprint {
104    /// Stable identifier.
105    pub id: SprintId,
106    /// Display title.
107    pub title: String,
108    /// Sprint goal (free description, multiple lines allowed).
109    pub goal: String,
110    /// Planned start date and time, or `None` when unset.
111    pub start: Option<DateTime<Utc>>,
112    /// Planned end date and time, or `None` when unset.
113    pub end: Option<DateTime<Utc>>,
114    /// Working hours per day; capacity is unavailable when unset.
115    pub daily_work_hours: Option<f64>,
116    /// Holidays within the period, deducted from the inclusive calendar-day count.
117    pub holiday_days: Option<u32>,
118    /// Fraction of daily capacity retained after meetings and interruptions (`0.0..=1.0`).
119    pub deduction_factor: Option<f64>,
120    /// Current state (`planned`, `active`, or `closed`).
121    pub state: SprintState,
122    pub created: DateTime<Utc>,
123    pub updated: DateTime<Utc>,
124}
125
126impl Sprint {
127    /// Create a minimal sprint in [`SprintState::Planned`] with an empty goal and no schedule.
128    ///
129    /// Return [`Error::EmptySprintTitle`] when `title` is empty or only whitespace.
130    pub fn new(id: SprintId, title: impl Into<String>, now: DateTime<Utc>) -> Result<Self> {
131        let title = title.into();
132        if title.trim().is_empty() {
133            return Err(Error::EmptySprintTitle);
134        }
135        Ok(Self {
136            id,
137            title,
138            goal: String::new(),
139            start: None,
140            end: None,
141            daily_work_hours: None,
142            holiday_days: None,
143            deduction_factor: None,
144            state: SprintState::Planned,
145            created: now,
146            updated: now,
147        })
148    }
149
150    /// Update the editable sprint details and refresh `updated`.
151    ///
152    /// Fields set to `None` remain unchanged. A blank title or inverted period is rejected before
153    /// any field changes are applied. Return [`Error::NothingToUpdate`] when no field is supplied.
154    pub fn update_details(
155        &mut self,
156        title: Option<String>,
157        goal: Option<String>,
158        period: Option<(DateTime<Utc>, DateTime<Utc>)>,
159        now: DateTime<Utc>,
160    ) -> Result<()> {
161        if title.is_none() && goal.is_none() && period.is_none() {
162            return Err(Error::NothingToUpdate);
163        }
164        if let Some(title) = &title
165            && title.trim().is_empty()
166        {
167            return Err(Error::EmptySprintTitle);
168        }
169        if let Some((start, end)) = period
170            && start > end
171        {
172            return Err(Error::InvalidSprintPeriod {
173                start: start.date_naive(),
174                end: end.date_naive(),
175            });
176        }
177
178        if let Some(title) = title {
179            self.title = title;
180        }
181        if let Some(goal) = goal {
182            self.goal = goal;
183        }
184        if let Some((start, end)) = period {
185            self.start = Some(start);
186            self.end = Some(end);
187        }
188        self.updated = now;
189        Ok(())
190    }
191
192    /// Start a sprint (`planned` → `active`) and update `updated`.
193    ///
194    /// A sprint without a non-blank goal is rejected with [`Error::EmptySprintGoal`].
195    /// Return [`Error::InvalidSprintTransition`] for any other starting state without changing it.
196    pub fn start(&mut self, now: DateTime<Utc>) -> Result<()> {
197        if self.state == SprintState::Planned && self.goal.trim().is_empty() {
198            return Err(Error::EmptySprintGoal);
199        }
200        self.transition(SprintState::Active, now)
201    }
202
203    /// Close a sprint (`active` → `closed`) and update `updated`.
204    ///
205    /// Return [`Error::InvalidSprintTransition`] for any other state without changing it.
206    pub fn close(&mut self, now: DateTime<Utc>) -> Result<()> {
207        self.transition(SprintState::Closed, now)
208    }
209
210    /// Update settings for capacity calculations.
211    ///
212    /// Count calendar days inclusively, subtract `holiday_days`, and multiply the result by
213    /// `daily_work_hours × deduction_factor`. Reject an unset period or invalid value without
214    /// changing existing settings.
215    pub fn set_capacity(
216        &mut self,
217        daily_work_hours: f64,
218        holiday_days: u32,
219        deduction_factor: f64,
220    ) -> Result<()> {
221        if !daily_work_hours.is_finite() || daily_work_hours < 0.0 {
222            return Err(Error::InvalidDailyWorkHours(daily_work_hours.to_string()));
223        }
224        if !deduction_factor.is_finite() || !(0.0..=1.0).contains(&deduction_factor) {
225            return Err(Error::InvalidDeductionFactor(deduction_factor.to_string()));
226        }
227        let calendar_days = self.calendar_days()?;
228        if holiday_days > calendar_days {
229            return Err(Error::InvalidSprintHolidays {
230                holidays: holiday_days,
231                calendar_days,
232            });
233        }
234        self.daily_work_hours = Some(daily_work_hours);
235        self.holiday_days = Some(holiday_days);
236        self.deduction_factor = Some(deduction_factor);
237        Ok(())
238    }
239
240    /// Return working days and available hours when the schedule and capacity settings are complete.
241    #[must_use]
242    pub fn capacity(&self) -> Option<SprintCapacity> {
243        let calendar_days = self.calendar_days().ok()?;
244        let (daily_work_hours, holiday_days, deduction_factor) = (
245            self.daily_work_hours?,
246            self.holiday_days?,
247            self.deduction_factor?,
248        );
249        let working_days = calendar_days.checked_sub(holiday_days)?;
250        Some(SprintCapacity {
251            working_days,
252            hours: f64::from(working_days) * daily_work_hours * deduction_factor,
253        })
254    }
255
256    fn calendar_days(&self) -> Result<u32> {
257        let (start, end) = self
258            .start
259            .zip(self.end)
260            .ok_or_else(|| Error::SprintCapacityPeriodUnset(self.id.clone()))?;
261        if start > end {
262            return Err(Error::InvalidSprintPeriod {
263                start: start.date_naive(),
264                end: end.date_naive(),
265            });
266        }
267        let days = (end.date_naive() - start.date_naive()).num_days() + 1;
268        u32::try_from(days).map_err(|_| Error::InvalidSprintPeriod {
269            start: start.date_naive(),
270            end: end.date_naive(),
271        })
272    }
273
274    /// Apply a forward-only state transition: `planned → active → closed`.
275    fn transition(&mut self, to: SprintState, now: DateTime<Utc>) -> Result<()> {
276        let allowed = matches!(
277            (self.state, to),
278            (SprintState::Planned, SprintState::Active)
279                | (SprintState::Active, SprintState::Closed)
280        );
281        if !allowed {
282            return Err(Error::InvalidSprintTransition {
283                from: self.state,
284                to,
285            });
286        }
287        self.state = to;
288        self.updated = now;
289        Ok(())
290    }
291}
292
293/// Sprint capacity calculation results.
294#[derive(Debug, Clone, PartialEq)]
295pub struct SprintCapacity {
296    /// Inclusive calendar days minus holidays.
297    pub working_days: u32,
298    /// Available working hours after deductions.
299    pub hours: f64,
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use chrono::{DateTime, Utc};
306
307    fn epoch() -> DateTime<Utc> {
308        DateTime::from_timestamp(0, 0).expect("valid epoch")
309    }
310
311    fn sid(s: &str) -> SprintId {
312        SprintId::new(s).expect("valid sprint id")
313    }
314
315    // --- SprintId ---
316
317    #[test]
318    fn sprint_id_accepts_safe_slugs() {
319        for ok in ["S-1", "sprint_1", "2026Q3", "A", "release-1-x"] {
320            assert!(SprintId::new(ok).is_ok(), "expected {ok:?} to be accepted");
321        }
322    }
323
324    #[test]
325    fn sprint_id_rejects_unsafe_slugs() {
326        for bad in ["", "S 1", "a/b", "a.b", "エス", "with\tspace"] {
327            assert!(
328                SprintId::new(bad).is_err(),
329                "expected {bad:?} to be rejected"
330            );
331        }
332    }
333
334    #[test]
335    fn sprint_id_display_and_parse_roundtrip() {
336        let id = sid("S-1");
337        assert_eq!(id.to_string(), "S-1");
338        assert_eq!("S-1".parse::<SprintId>().unwrap(), id);
339        assert_eq!(id.as_str(), "S-1");
340    }
341
342    // --- SprintState ---
343
344    #[test]
345    fn sprint_state_string_roundtrip() {
346        for (state, text) in [
347            (SprintState::Planned, "planned"),
348            (SprintState::Active, "active"),
349            (SprintState::Closed, "closed"),
350        ] {
351            assert_eq!(state.as_str(), text);
352            assert_eq!(state.to_string(), text);
353            assert_eq!(text.parse::<SprintState>().unwrap(), state);
354        }
355    }
356
357    #[test]
358    fn sprint_state_rejects_unknown() {
359        let err = "archived".parse::<SprintState>().unwrap_err();
360        assert_eq!(err, Error::InvalidSprintState("archived".to_string()));
361    }
362
363    // --- Sprint construction ---
364
365    #[test]
366    fn new_sprint_uses_planned_defaults() {
367        let s = Sprint::new(sid("S-1"), "Sprint 1", epoch()).unwrap();
368        assert_eq!(s.id, sid("S-1"));
369        assert_eq!(s.title, "Sprint 1");
370        assert_eq!(s.goal, "");
371        assert_eq!(s.start, None);
372        assert_eq!(s.end, None);
373        assert_eq!(s.daily_work_hours, None);
374        assert_eq!(s.holiday_days, None);
375        assert_eq!(s.deduction_factor, None);
376        assert_eq!(s.capacity(), None);
377        assert_eq!(s.state, SprintState::Planned);
378        assert_eq!(s.created, epoch());
379        assert_eq!(s.updated, epoch());
380    }
381
382    #[test]
383    fn new_sprint_rejects_empty_title() {
384        let err = Sprint::new(sid("S-1"), "   ", epoch()).unwrap_err();
385        assert_eq!(err, Error::EmptySprintTitle);
386    }
387
388    #[test]
389    fn update_details_changes_title_goal_period_and_timestamp() {
390        let mut sprint = Sprint::new(sid("S-1"), "Planning", epoch()).unwrap();
391        let start = epoch() + chrono::Duration::days(1);
392        let end = epoch() + chrono::Duration::days(5);
393        let updated = epoch() + chrono::Duration::days(10);
394
395        sprint
396            .update_details(
397                Some("Execution".to_string()),
398                Some("Ship the sprint".to_string()),
399                Some((start, end)),
400                updated,
401            )
402            .unwrap();
403
404        assert_eq!(sprint.title, "Execution");
405        assert_eq!(sprint.goal, "Ship the sprint");
406        assert_eq!(sprint.start, Some(start));
407        assert_eq!(sprint.end, Some(end));
408        assert_eq!(sprint.updated, updated);
409        assert_eq!(sprint.created, epoch());
410    }
411
412    #[test]
413    fn update_details_rejects_invalid_values_without_mutating() {
414        let mut sprint = Sprint::new(sid("S-1"), "Planning", epoch()).unwrap();
415        let original = sprint.clone();
416        let later = epoch() + chrono::Duration::days(1);
417
418        assert_eq!(
419            sprint.update_details(Some("   ".to_string()), None, None, later),
420            Err(Error::EmptySprintTitle)
421        );
422        assert_eq!(sprint, original);
423
424        let start = epoch() + chrono::Duration::days(5);
425        let end = epoch() + chrono::Duration::days(1);
426        assert_eq!(
427            sprint.update_details(None, None, Some((start, end)), later),
428            Err(Error::InvalidSprintPeriod {
429                start: start.date_naive(),
430                end: end.date_naive(),
431            })
432        );
433        assert_eq!(sprint, original);
434
435        assert_eq!(
436            sprint.update_details(None, None, None, later),
437            Err(Error::NothingToUpdate)
438        );
439        assert_eq!(sprint, original);
440    }
441
442    #[test]
443    fn capacity_uses_inclusive_calendar_days_and_deductions() {
444        let mut sprint = Sprint::new(sid("S-1"), "Sprint 1", epoch()).unwrap();
445        sprint.start = Some(
446            chrono::NaiveDate::from_ymd_opt(2026, 7, 6)
447                .unwrap()
448                .and_hms_opt(0, 0, 0)
449                .unwrap()
450                .and_utc(),
451        );
452        sprint.end = Some(
453            chrono::NaiveDate::from_ymd_opt(2026, 7, 10)
454                .unwrap()
455                .and_hms_opt(0, 0, 0)
456                .unwrap()
457                .and_utc(),
458        );
459
460        sprint.set_capacity(8.0, 1, 0.8).unwrap();
461
462        assert_eq!(
463            sprint.capacity(),
464            Some(SprintCapacity {
465                working_days: 4,
466                hours: 25.6,
467            })
468        );
469    }
470
471    #[test]
472    fn capacity_rejects_invalid_values_and_holidays_outside_period() {
473        let mut sprint = Sprint::new(sid("S-1"), "Sprint 1", epoch()).unwrap();
474        sprint.start = Some(epoch());
475        sprint.end = Some(epoch());
476
477        assert_eq!(
478            sprint.set_capacity(-1.0, 0, 1.0),
479            Err(Error::InvalidDailyWorkHours("-1".to_string()))
480        );
481        assert_eq!(
482            sprint.set_capacity(8.0, 0, 1.1),
483            Err(Error::InvalidDeductionFactor("1.1".to_string()))
484        );
485        assert_eq!(
486            sprint.set_capacity(8.0, 2, 1.0),
487            Err(Error::InvalidSprintHolidays {
488                holidays: 2,
489                calendar_days: 1,
490            })
491        );
492    }
493
494    // --- transitions ---
495
496    #[test]
497    fn start_moves_planned_to_active_and_updates_timestamp() {
498        let mut s = Sprint::new(sid("S-1"), "Sprint 1", epoch()).unwrap();
499        s.goal = "Ship the sprint".to_string();
500        let later = epoch() + chrono::Duration::seconds(60);
501
502        s.start(later).unwrap();
503
504        assert_eq!(s.state, SprintState::Active);
505        assert_eq!(s.updated, later);
506        assert_eq!(s.created, epoch(), "created must not change");
507    }
508
509    #[test]
510    fn start_requires_a_non_empty_goal() {
511        let mut s = Sprint::new(sid("S-1"), "Sprint 1", epoch()).unwrap();
512
513        let err = s.start(epoch()).unwrap_err();
514
515        assert_eq!(err, Error::EmptySprintGoal);
516        assert_eq!(s.state, SprintState::Planned);
517    }
518
519    #[test]
520    fn close_moves_active_to_closed() {
521        let mut s = Sprint::new(sid("S-1"), "Sprint 1", epoch()).unwrap();
522        s.goal = "Ship the sprint".to_string();
523        s.start(epoch()).unwrap();
524        let later = epoch() + chrono::Duration::seconds(120);
525
526        s.close(later).unwrap();
527
528        assert_eq!(s.state, SprintState::Closed);
529        assert_eq!(s.updated, later);
530    }
531
532    #[test]
533    fn start_from_non_planned_is_rejected_and_leaves_state() {
534        let mut s = Sprint::new(sid("S-1"), "Sprint 1", epoch()).unwrap();
535        s.goal = "Ship the sprint".to_string();
536        s.start(epoch()).unwrap(); // active
537
538        let err = s.start(epoch()).unwrap_err();
539        assert_eq!(
540            err,
541            Error::InvalidSprintTransition {
542                from: SprintState::Active,
543                to: SprintState::Active,
544            }
545        );
546        assert_eq!(s.state, SprintState::Active, "state unchanged on failure");
547    }
548
549    #[test]
550    fn close_from_planned_is_rejected() {
551        let mut s = Sprint::new(sid("S-1"), "Sprint 1", epoch()).unwrap();
552        let err = s.close(epoch()).unwrap_err();
553        assert_eq!(
554            err,
555            Error::InvalidSprintTransition {
556                from: SprintState::Planned,
557                to: SprintState::Closed,
558            }
559        );
560        assert_eq!(s.state, SprintState::Planned);
561    }
562}