use super::CommandContext;
use crate::SprintUpdate;
use crate::{KanbanError, KanbanResult};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum SprintCommand {
Create(CreateSprint),
Update(UpdateSprint),
Activate(ActivateSprint),
Complete(CompleteSprint),
Cancel(CancelSprint),
Delete(DeleteSprint),
}
impl SprintCommand {
pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {
match self {
SprintCommand::Create(c) => c.execute(context),
SprintCommand::Update(c) => c.execute(context),
SprintCommand::Activate(c) => c.execute(context),
SprintCommand::Complete(c) => c.execute(context),
SprintCommand::Cancel(c) => c.execute(context),
SprintCommand::Delete(c) => c.execute(context),
}
}
pub fn description(&self) -> String {
match self {
SprintCommand::Create(c) => c.description(),
SprintCommand::Update(c) => c.description(),
SprintCommand::Activate(c) => c.description(),
SprintCommand::Complete(c) => c.description(),
SprintCommand::Cancel(c) => c.description(),
SprintCommand::Delete(c) => c.description(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateSprint {
pub sprint_id: Uuid,
pub updates: SprintUpdate,
}
impl UpdateSprint {
pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {
let mut updates = self.updates.clone();
if !matches!(updates.card_prefix, crate::FieldUpdate::NoChange) {
let sprint = context.get_sprint(self.sprint_id)?;
let board_id = sprint.board_id;
let sprint_id = sprint.id;
let has_cards = !context.store.list_cards_by_sprint(sprint_id)?.is_empty()
|| context
.store
.list_archived_cards()?
.iter()
.any(|ac| ac.card.sprint_id == Some(sprint_id));
if has_cards {
return Err(KanbanError::validation(
"sprint card_prefix cannot be changed after cards have been assigned",
));
}
if let crate::FieldUpdate::Set(ref new_prefix) = updates.card_prefix {
let new_prefix_lower = new_prefix.to_lowercase();
let board = context.get_board(board_id)?;
if board
.card_prefix
.as_deref()
.map(|p| p.to_lowercase())
.as_deref()
== Some(new_prefix_lower.as_str())
{
return Err(KanbanError::validation(
"sprint card_prefix must not match the board card_prefix",
));
}
let sibling_collision = context
.store
.list_sprints_by_board(board_id)?
.iter()
.filter(|s| s.id != sprint_id)
.any(|s| {
s.card_prefix
.as_deref()
.map(|p| p.to_lowercase())
.as_deref()
== Some(new_prefix_lower.as_str())
});
if sibling_collision {
return Err(KanbanError::validation(
"sprint card_prefix must be unique within the board",
));
}
}
}
if let Some(ref name) = updates.name {
let sprint = context.get_sprint(self.sprint_id)?;
let board_id = sprint.board_id;
let mut board = context.get_board(board_id)?;
let idx = board.add_sprint_name_at_used_index(name.clone());
updates.name_index = crate::FieldUpdate::Set(idx);
context.store.upsert_board(board)?;
}
let mut sprint = context.get_sprint(self.sprint_id)?;
sprint.update(updates);
context.store.upsert_sprint(sprint)?;
Ok(())
}
pub fn description(&self) -> String {
"Update sprint".to_string()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateSprint {
pub id: Uuid,
pub board_id: Uuid,
pub name: Option<String>,
pub default_sprint_prefix: String,
pub explicit_prefix: Option<String>,
pub auto_consume_name: bool,
}
impl CreateSprint {
pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {
let sprints_snapshot = context.store.list_sprints_by_board(self.board_id)?;
let mut board = context.get_board(self.board_id)?;
let effective_prefix = self
.explicit_prefix
.clone()
.or_else(|| board.sprint_prefix.clone())
.unwrap_or_else(|| self.default_sprint_prefix.clone());
board.ensure_sprint_counter_initialized(&effective_prefix, &sprints_snapshot);
let sprint_number = board.get_next_sprint_number(&effective_prefix);
let name_index = match &self.name {
Some(name) if !name.trim().is_empty() => {
Some(board.add_sprint_name_at_used_index(name.clone()))
}
_ if self.auto_consume_name => board.consume_sprint_name(),
_ => None,
};
let mut sprint = crate::Sprint::new(
self.board_id,
sprint_number,
name_index,
Some(effective_prefix),
);
sprint.id = self.id;
context.store.upsert_board(board)?;
context.store.upsert_sprint(sprint)?;
Ok(())
}
pub fn description(&self) -> String {
format!("Create sprint for board {}", self.board_id)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivateSprint {
pub sprint_id: Uuid,
pub duration_days: u32,
}
impl ActivateSprint {
pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {
let mut sprint = context.get_sprint(self.sprint_id)?;
sprint.activate(self.duration_days);
context.store.upsert_sprint(sprint)?;
Ok(())
}
pub fn description(&self) -> String {
format!("Activate sprint {}", self.sprint_id)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompleteSprint {
pub sprint_id: Uuid,
}
impl CompleteSprint {
pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {
let mut sprint = context.get_sprint(self.sprint_id)?;
sprint.complete();
context.store.upsert_sprint(sprint)?;
Ok(())
}
pub fn description(&self) -> String {
format!("Complete sprint {}", self.sprint_id)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CancelSprint {
pub sprint_id: Uuid,
}
impl CancelSprint {
pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {
let mut sprint = context.get_sprint(self.sprint_id)?;
sprint.cancel();
context.store.upsert_sprint(sprint)?;
Ok(())
}
pub fn description(&self) -> String {
format!("Cancel sprint {}", self.sprint_id)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteSprint {
pub sprint_id: Uuid,
#[serde(default = "chrono::Utc::now")]
pub timestamp: chrono::DateTime<chrono::Utc>,
}
impl DeleteSprint {
pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {
context
.store
.clear_sprint_from_cards(self.sprint_id, self.timestamp)?;
context
.store
.clear_sprint_from_archived_cards(self.sprint_id, self.timestamp)?;
context.store.delete_sprint(self.sprint_id)?;
Ok(())
}
pub fn description(&self) -> String {
format!("Delete sprint {}", self.sprint_id)
}
}
#[cfg(test)]
mod tests {
use super::super::test_helpers::TestContext;
use super::*;
use crate::DataStore;
#[test]
fn test_update_sprint_not_found_returns_error() {
let tc = TestContext::new();
let context = tc.as_command_context();
let cmd = UpdateSprint {
sprint_id: Uuid::new_v4(),
updates: SprintUpdate::default(),
};
let result = cmd.execute(&context);
assert!(result.unwrap_err().is_not_found());
}
#[test]
fn test_update_sprint_name_with_nonexistent_board_returns_error() {
let tc = TestContext::new();
let nonexistent_board_id = Uuid::new_v4();
let sprint = crate::Sprint::new(nonexistent_board_id, 1, None, None);
let sprint_id = sprint.id;
tc.store.upsert_sprint(sprint).unwrap();
let context = tc.as_command_context();
let cmd = UpdateSprint {
sprint_id,
updates: SprintUpdate {
name: Some("New Name".to_string()),
..Default::default()
},
};
let result = cmd.execute(&context);
assert!(result.unwrap_err().is_not_found());
}
#[test]
fn test_activate_sprint_not_found_returns_error() {
let tc = TestContext::new();
let context = tc.as_command_context();
let cmd = ActivateSprint {
sprint_id: Uuid::new_v4(),
duration_days: 14,
};
let result = cmd.execute(&context);
assert!(result.unwrap_err().is_not_found());
}
#[test]
fn test_complete_sprint_not_found_returns_error() {
let tc = TestContext::new();
let context = tc.as_command_context();
let cmd = CompleteSprint {
sprint_id: Uuid::new_v4(),
};
let result = cmd.execute(&context);
assert!(result.unwrap_err().is_not_found());
}
#[test]
fn test_cancel_sprint_not_found_returns_error() {
let tc = TestContext::new();
let context = tc.as_command_context();
let cmd = CancelSprint {
sprint_id: Uuid::new_v4(),
};
let result = cmd.execute(&context);
assert!(result.unwrap_err().is_not_found());
}
#[test]
fn test_create_sprint_auto_consume_name_uses_name_pool() {
let tc = TestContext::new();
let mut board = crate::Board::new("Test".to_string(), None);
board.sprint_names = vec!["Alpha".to_string(), "Beta".to_string()];
let board_id = board.id;
tc.store.upsert_board(board).unwrap();
let context = tc.as_command_context();
let cmd = CreateSprint {
id: Uuid::new_v4(),
board_id,
name: None,
default_sprint_prefix: "Sprint".to_string(),
explicit_prefix: None,
auto_consume_name: true,
};
cmd.execute(&context).unwrap();
let sprints = tc.store.list_all_sprints().unwrap();
assert_eq!(sprints.len(), 1);
let sprint = &sprints[0];
let board = tc.store.get_board(board_id).unwrap().unwrap();
assert_eq!(
sprint.get_name(&board),
Some("Alpha"),
"auto_consume_name should consume the first available sprint name"
);
}
#[test]
fn test_update_sprint_card_prefix_locked_after_card_assigned_returns_validation_error() {
let tc = TestContext::new();
let mut board = crate::Board::new("B".to_string(), Some("KAN".to_string()));
let col = crate::Column::new(board.id, "Col".to_string(), 0);
let sprint = crate::Sprint::new(board.id, 1, None, Some("SPR".to_string()));
let sprint_id = sprint.id;
let mut card = crate::Card::new(&mut board, col.id, "C".to_string(), 0);
card.sprint_id = Some(sprint_id);
tc.store.upsert_board(board).unwrap();
tc.store.upsert_column(col).unwrap();
tc.store.upsert_sprint(sprint).unwrap();
tc.store.upsert_card(card).unwrap();
let context = tc.as_command_context();
let cmd = UpdateSprint {
sprint_id,
updates: crate::SprintUpdate {
card_prefix: crate::FieldUpdate::Set("NEW".to_string()),
..Default::default()
},
};
let err = cmd.execute(&context).unwrap_err();
assert!(err.is_validation());
}
#[test]
fn test_update_sprint_card_prefix_locked_after_archived_card_assigned_returns_validation_error()
{
let tc = TestContext::new();
let mut board = crate::Board::new("B".to_string(), Some("KAN".to_string()));
let col = crate::Column::new(board.id, "Col".to_string(), 0);
let sprint = crate::Sprint::new(board.id, 1, None, Some("SPR".to_string()));
let sprint_id = sprint.id;
let mut card = crate::Card::new(&mut board, col.id, "C".to_string(), 0);
card.sprint_id = Some(sprint_id);
let archived = crate::ArchivedCard::new(card, col.id, 0);
tc.store.upsert_board(board).unwrap();
tc.store.upsert_column(col).unwrap();
tc.store.upsert_sprint(sprint).unwrap();
tc.store.insert_archived_card(archived).unwrap();
let context = tc.as_command_context();
let cmd = UpdateSprint {
sprint_id,
updates: crate::SprintUpdate {
card_prefix: crate::FieldUpdate::Set("NEW".to_string()),
..Default::default()
},
};
let err = cmd.execute(&context).unwrap_err();
assert!(err.is_validation());
}
#[test]
fn test_update_sprint_clear_card_prefix_locked_after_card_assigned_returns_validation_error() {
let tc = TestContext::new();
let mut board = crate::Board::new("B".to_string(), Some("KAN".to_string()));
let col = crate::Column::new(board.id, "Col".to_string(), 0);
let sprint = crate::Sprint::new(board.id, 1, None, Some("SPR".to_string()));
let sprint_id = sprint.id;
let mut card = crate::Card::new(&mut board, col.id, "C".to_string(), 0);
card.sprint_id = Some(sprint_id);
tc.store.upsert_board(board).unwrap();
tc.store.upsert_column(col).unwrap();
tc.store.upsert_sprint(sprint).unwrap();
tc.store.upsert_card(card).unwrap();
let context = tc.as_command_context();
let cmd = UpdateSprint {
sprint_id,
updates: crate::SprintUpdate {
card_prefix: crate::FieldUpdate::Clear,
..Default::default()
},
};
let err = cmd.execute(&context).unwrap_err();
assert!(err.is_validation());
}
#[test]
fn test_update_sprint_card_prefix_collides_with_board_prefix_returns_validation_error() {
let tc = TestContext::new();
let board = crate::Board::new("B".to_string(), Some("KAN".to_string()));
let board_id = board.id;
let sprint = crate::Sprint::new(board_id, 1, None, Some("SPR".to_string()));
let sprint_id = sprint.id;
tc.store.upsert_board(board).unwrap();
tc.store.upsert_sprint(sprint).unwrap();
let context = tc.as_command_context();
let cmd = UpdateSprint {
sprint_id,
updates: crate::SprintUpdate {
card_prefix: crate::FieldUpdate::Set("KAN".to_string()),
..Default::default()
},
};
let err = cmd.execute(&context).unwrap_err();
assert!(err.is_validation());
}
#[test]
fn test_update_sprint_card_prefix_case_insensitive_collision_returns_validation_error() {
let tc = TestContext::new();
let board = crate::Board::new("B".to_string(), Some("KAN".to_string()));
let board_id = board.id;
let sprint = crate::Sprint::new(board_id, 1, None, Some("SPR".to_string()));
let sprint_id = sprint.id;
tc.store.upsert_board(board).unwrap();
tc.store.upsert_sprint(sprint).unwrap();
let context = tc.as_command_context();
let cmd = UpdateSprint {
sprint_id,
updates: crate::SprintUpdate {
card_prefix: crate::FieldUpdate::Set("kan".to_string()),
..Default::default()
},
};
let err = cmd.execute(&context).unwrap_err();
assert!(err.is_validation());
}
#[test]
fn test_update_sprint_card_prefix_collides_with_sibling_sprint_returns_validation_error() {
let tc = TestContext::new();
let board = crate::Board::new("B".to_string(), Some("KAN".to_string()));
let board_id = board.id;
let mut sprint1 = crate::Sprint::new(board_id, 1, None, None);
sprint1.card_prefix = Some("SPR".to_string());
let sprint2 = crate::Sprint::new(board_id, 2, None, None);
let sprint2_id = sprint2.id;
tc.store.upsert_board(board).unwrap();
tc.store.upsert_sprint(sprint1).unwrap();
tc.store.upsert_sprint(sprint2).unwrap();
let context = tc.as_command_context();
let cmd = UpdateSprint {
sprint_id: sprint2_id,
updates: crate::SprintUpdate {
card_prefix: crate::FieldUpdate::Set("SPR".to_string()),
..Default::default()
},
};
let err = cmd.execute(&context).unwrap_err();
assert!(err.is_validation());
}
#[test]
fn test_delete_sprint_clears_sprint_from_cards_with_command_timestamp() {
use chrono::{TimeZone, Utc};
let tc = TestContext::new();
let board = crate::Board::new("B".to_string(), Some("KAN".to_string()));
let board_id = board.id;
let col = crate::Column::new(board_id, "Col".to_string(), 0);
let sprint = crate::Sprint::new(board_id, 1, None, None);
let sprint_id = sprint.id;
tc.store.upsert_board(board).unwrap();
tc.store.upsert_column(col.clone()).unwrap();
tc.store.upsert_sprint(sprint).unwrap();
let mut card = crate::Card::new(
&mut crate::Board::new("B".to_string(), Some("KAN".to_string())),
col.id,
"C".to_string(),
0,
);
card.sprint_id = Some(sprint_id);
let card_id = card.id;
tc.store.upsert_card(card).unwrap();
let fixed_time = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
let context = tc.as_command_context();
let cmd = DeleteSprint {
sprint_id,
timestamp: fixed_time,
};
cmd.execute(&context).unwrap();
let card = tc.store.get_card(card_id).unwrap().unwrap();
assert_eq!(
card.updated_at, fixed_time,
"clear_sprint_from_cards should use the command's timestamp, not Utc::now()"
);
assert_eq!(card.sprint_id, None);
}
#[test]
fn test_delete_sprint_uses_embedded_timestamp() {
use chrono::{TimeZone, Utc};
let tc = TestContext::new();
let board = crate::Board::new("B".to_string(), Some("KAN".to_string()));
let board_id = board.id;
let col = crate::Column::new(board_id, "Col".to_string(), 0);
let sprint = crate::Sprint::new(board_id, 1, None, None);
let sprint_id = sprint.id;
tc.store.upsert_board(board).unwrap();
tc.store.upsert_column(col.clone()).unwrap();
tc.store.upsert_sprint(sprint).unwrap();
let card = crate::Card {
id: Uuid::new_v4(),
column_id: col.id,
title: "C".to_string(),
description: None,
priority: crate::CardPriority::Medium,
status: crate::CardStatus::Todo,
position: 0,
due_date: None,
points: None,
card_number: 1,
sprint_id: Some(sprint_id),
created_at: Utc::now(),
updated_at: Utc::now(),
completed_at: None,
sprint_logs: Vec::new(),
};
let archived = crate::ArchivedCard::new(card, col.id, 0);
tc.store.insert_archived_card(archived).unwrap();
let fixed_time = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
let context = tc.as_command_context();
let cmd = DeleteSprint {
sprint_id,
timestamp: fixed_time,
};
cmd.execute(&context).unwrap();
let archived_cards = tc.store.list_archived_cards().unwrap();
assert_eq!(archived_cards.len(), 1);
assert_eq!(archived_cards[0].card.updated_at, fixed_time);
assert_eq!(archived_cards[0].card.sprint_id, None);
}
#[test]
fn test_update_sprint_card_prefix_unique_valid_succeeds() {
let tc = TestContext::new();
let board = crate::Board::new("B".to_string(), Some("KAN".to_string()));
let board_id = board.id;
let sprint = crate::Sprint::new(board_id, 1, None, Some("SPR".to_string()));
let sprint_id = sprint.id;
tc.store.upsert_board(board).unwrap();
tc.store.upsert_sprint(sprint).unwrap();
let context = tc.as_command_context();
let cmd = UpdateSprint {
sprint_id,
updates: crate::SprintUpdate {
card_prefix: crate::FieldUpdate::Set("UNIQUE".to_string()),
..Default::default()
},
};
assert!(cmd.execute(&context).is_ok());
let sprint = tc.store.get_sprint(sprint_id).unwrap().unwrap();
assert_eq!(sprint.card_prefix, Some("UNIQUE".to_string()));
}
}