//! Sprint domain model. //! //! A sprint is a project-scoped planning aggregate. Ticket membership remains //! authoritative on [`crate::issue::Issue`]. use std::collections::HashSet; use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::ids::SprintId; use crate::issue::IssueActor; /// Reorderable execution position. Values start at 1. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] #[serde(transparent)] pub struct SprintOrder(u32); impl SprintOrder { /// Builds a sprint order. /// /// # Errors /// [`SprintError::InvalidOrder`] when `value == 0`. pub fn new(value: u32) -> Result { if value == 0 { return Err(SprintError::InvalidOrder); } Ok(Self(value)) } /// Returns the raw order. #[must_use] pub const fn get(self) -> u32 { self.0 } } impl std::fmt::Display for SprintOrder { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } /// Optimistic-concurrency version. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] #[serde(transparent)] pub struct SprintVersion(u64); impl SprintVersion { /// Initial version assigned to a newly-created sprint. pub const INITIAL: Self = Self(1); /// Builds a version. /// /// # Errors /// [`SprintError::InvalidVersion`] when `value == 0`. pub fn new(value: u64) -> Result { if value == 0 { return Err(SprintError::InvalidVersion); } Ok(Self(value)) } /// Returns the raw version. #[must_use] pub const fn get(self) -> u64 { self.0 } /// Returns the next optimistic version. #[must_use] pub const fn next(self) -> Self { Self(self.0 + 1) } } impl std::fmt::Display for SprintVersion { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } /// Sprint lifecycle status. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum SprintStatus { /// Planned and not active yet. Planned, /// Current sprint. Active, /// Completed sprint. Done, } impl Default for SprintStatus { fn default() -> Self { Self::Planned } } /// Sprint aggregate. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Sprint { /// Stable UUID. pub id: SprintId, /// Reorderable execution position. pub order: SprintOrder, /// Human-readable name. Empty means UI may display `Sprint {order}`. pub name: String, /// Lifecycle status. pub status: SprintStatus, /// Creator. pub created_by: IssueActor, /// Last updater. pub updated_by: IssueActor, /// Creation time, epoch milliseconds. pub created_at: u64, /// Last update time, epoch milliseconds. pub updated_at: u64, /// Optimistic-concurrency version. pub version: SprintVersion, } impl Sprint { /// Builds a new sprint with version 1. /// /// # Errors /// [`SprintError`] when invariants are violated. pub fn new( id: SprintId, order: SprintOrder, name: impl Into, status: Option, actor: IssueActor, now_ms: u64, ) -> Result { let sprint = Self { id, order, name: name.into(), status: status.unwrap_or_default(), created_by: actor.clone(), updated_by: actor, created_at: now_ms, updated_at: now_ms, version: SprintVersion::INITIAL, }; sprint.validate()?; Ok(sprint) } /// Rehydrates a persisted sprint. /// /// # Errors /// [`SprintError`] when persisted data violates invariants. pub fn rehydrate(sprint: Self) -> Result { sprint.validate()?; Ok(sprint) } /// Validates invariants local to one sprint. /// /// # Errors /// [`SprintError`] when an invariant is violated. pub fn validate(&self) -> Result<(), SprintError> { SprintOrder::new(self.order.get())?; SprintVersion::new(self.version.get())?; Ok(()) } /// Applies a mutation and increments the version. /// /// # Errors /// [`SprintError`] when the resulting sprint violates invariants. pub fn mutate( mut self, actor: IssueActor, now_ms: u64, f: impl FnOnce(&mut Self), ) -> Result { f(&mut self); self.updated_by = actor; self.updated_at = now_ms; self.version = self.version.next(); self.validate()?; Ok(self) } } /// Index row used by stores and list use cases. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SprintIndexEntry { /// Stable sprint id. pub id: SprintId, /// Relative path to the sprint folder. pub path: String, /// Reorderable position. pub order: SprintOrder, /// Human-readable name. pub name: String, /// Lifecycle status. pub status: SprintStatus, /// Last update time. pub updated_at: u64, /// Current optimistic version. pub version: SprintVersion, } impl From<&Sprint> for SprintIndexEntry { fn from(sprint: &Sprint) -> Self { Self { id: sprint.id, path: sprint.id.to_string(), order: sprint.order, name: sprint.name.clone(), status: sprint.status, updated_at: sprint.updated_at, version: sprint.version, } } } /// Validates that sprint orders are unique and contiguous from 1. /// /// # Errors /// [`SprintError::DuplicateOrder`] or [`SprintError::NonContiguousOrder`]. pub fn validate_sprint_ordering<'a>( sprints: impl IntoIterator, ) -> Result<(), SprintError> { let mut orders: Vec = sprints .into_iter() .map(|sprint| sprint.order.get()) .collect(); orders.sort_unstable(); let mut seen = HashSet::new(); for order in &orders { if !seen.insert(*order) { return Err(SprintError::DuplicateOrder(*order)); } } for (idx, order) in orders.into_iter().enumerate() { let expected = (idx as u32) + 1; if order != expected { return Err(SprintError::NonContiguousOrder { expected, actual: order, }); } } Ok(()) } /// Domain errors for sprint invariants and value objects. #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum SprintError { /// Sprint order is strictly positive. #[error("sprint order must be greater than zero")] InvalidOrder, /// Sprint versions are strictly positive. #[error("sprint version must be greater than zero")] InvalidVersion, /// Two sprints share the same order. #[error("duplicate sprint order {0}")] DuplicateOrder(u32), /// Sprint ordering must be contiguous from 1. #[error("non-contiguous sprint order: expected {expected}, actual {actual}")] NonContiguousOrder { /// Expected contiguous value. expected: u32, /// Actual encountered value. actual: u32, }, } #[cfg(test)] mod tests { use super::*; use crate::ids::SprintId; use uuid::Uuid; fn sid(n: u128) -> SprintId { SprintId::from_uuid(Uuid::from_u128(n)) } fn sprint(order: u32) -> Sprint { Sprint::new( sid(order as u128), SprintOrder::new(order).unwrap(), "", None, IssueActor::User, 10, ) .unwrap() } #[test] fn sprint_defaults_to_planned_and_version_one() { let sprint = sprint(1); assert_eq!(sprint.status, SprintStatus::Planned); assert_eq!(sprint.version, SprintVersion::INITIAL); } #[test] fn order_and_version_reject_zero() { assert_eq!(SprintOrder::new(0), Err(SprintError::InvalidOrder)); assert_eq!(SprintVersion::new(0), Err(SprintError::InvalidVersion)); } #[test] fn validate_ordering_accepts_contiguous_unique_orders() { let a = sprint(1); let b = sprint(2); assert_eq!(validate_sprint_ordering([&b, &a]), Ok(())); } #[test] fn validate_ordering_rejects_duplicates_and_gaps() { let a = sprint(1); let duplicate = sprint(1); assert_eq!( validate_sprint_ordering([&a, &duplicate]), Err(SprintError::DuplicateOrder(1)) ); let c = sprint(3); assert_eq!( validate_sprint_ordering([&a, &c]), Err(SprintError::NonContiguousOrder { expected: 2, actual: 3, }) ); } #[test] fn mutate_increments_version_and_tracks_actor() { let updated = sprint(1) .mutate(IssueActor::System, 20, |sprint| { sprint.name = "Delivery".to_owned(); }) .unwrap(); assert_eq!(updated.name, "Delivery"); assert_eq!(updated.updated_by, IssueActor::System); assert_eq!(updated.updated_at, 20); assert_eq!(updated.version.get(), 2); } }