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, Clone, PartialEq)]
103pub struct Sprint {
104 pub id: SprintId,
106 pub title: String,
108 pub goal: String,
110 pub start: Option<DateTime<Utc>>,
112 pub end: Option<DateTime<Utc>>,
114 pub daily_work_hours: Option<f64>,
116 pub holiday_days: Option<u32>,
118 pub deduction_factor: Option<f64>,
120 pub state: SprintState,
122 pub created: DateTime<Utc>,
123 pub updated: DateTime<Utc>,
124}
125
126impl Sprint {
127 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 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 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 pub fn close(&mut self, now: DateTime<Utc>) -> Result<()> {
207 self.transition(SprintState::Closed, now)
208 }
209
210 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 #[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 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#[derive(Debug, Clone, PartialEq)]
295pub struct SprintCapacity {
296 pub working_days: u32,
298 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 #[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 #[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 #[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 #[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(); 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}