1use crate::error::{Error, Result};
7use chrono::{DateTime, Utc};
8use std::fmt;
9use std::str::FromStr;
10
11#[derive(Debug, Clone, PartialEq, Eq, Hash)]
16pub struct SprintId(String);
17
18impl SprintId {
19 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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum SprintState {
59 Planned,
61 Active,
63 Closed,
65}
66
67impl SprintState {
68 #[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#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
103pub struct SprintSpillover {
104 pub points: u32,
106 pub items: u32,
108 pub unestimated_items: u32,
110}
111
112#[derive(Debug, Clone, PartialEq)]
117pub struct Sprint {
118 pub id: SprintId,
120 pub title: String,
122 pub goal: String,
124 pub start: Option<DateTime<Utc>>,
126 pub end: Option<DateTime<Utc>>,
128 pub daily_work_hours: Option<f64>,
130 pub holiday_days: Option<u32>,
132 pub deduction_factor: Option<f64>,
134 pub spillover: SprintSpillover,
136 pub state: SprintState,
138 pub closed_at: Option<DateTime<Utc>>,
140 pub created: DateTime<Utc>,
141 pub updated: DateTime<Utc>,
142}
143
144impl Sprint {
145 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 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 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 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 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 #[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 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#[derive(Debug, Clone, PartialEq)]
318pub struct SprintCapacity {
319 pub working_days: u32,
321 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 #[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 #[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 #[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 #[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(); 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}