Ticket #10 — introduction du modèle de sprints côté backend. - Domaine : nouvel agrégat Sprint (sprint.rs), IDs, événements et invariants ; rattachement des issues à un sprint (issue.rs) et ports associés. - Application : use-cases sprints (application/src/sprints) + erreurs dédiées. - Infrastructure : store de sprints (infrastructure/src/sprints.rs), adaptation du store d'issues et exposition MCP via orchestrator/mcp/tickets.rs. - app-tauri : commandes, state et events pour piloter les sprints depuis l'UI. Tests domaine/application/infra/app-tauri verts (sprint_usecases, sprint_store, issue_store, mcp_server). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
351 lines
9.3 KiB
Rust
351 lines
9.3 KiB
Rust
//! 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<Self, SprintError> {
|
|
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<Self, SprintError> {
|
|
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<String>,
|
|
status: Option<SprintStatus>,
|
|
actor: IssueActor,
|
|
now_ms: u64,
|
|
) -> Result<Self, SprintError> {
|
|
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<Self, SprintError> {
|
|
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<Self, SprintError> {
|
|
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<Item = &'a Sprint>,
|
|
) -> Result<(), SprintError> {
|
|
let mut orders: Vec<u32> = 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);
|
|
}
|
|
}
|