use super::{Command, CommandContext};
use crate::data_store::DataStore;
use crate::field_update::FieldUpdate;
use crate::KanbanResult;
use crate::{ArchivedCard, Board, Card, Column, DependencyGraph, KanbanError, Sprint};
use kanban_core::Editable;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum BoardCommand {
Create(CreateBoard),
Update(UpdateBoard),
SetTaskSort(SetBoardTaskSort),
SetTaskListView(SetBoardTaskListView),
Delete(DeleteBoard),
ApplySettings(ApplyBoardSettings),
Import(ImportEntities),
RestoreSprintPool(RestoreSprintPool),
}
impl BoardCommand {
pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {
match self {
BoardCommand::Create(c) => c.execute(context),
BoardCommand::Update(c) => c.execute(context),
BoardCommand::SetTaskSort(c) => c.execute(context),
BoardCommand::SetTaskListView(c) => c.execute(context),
BoardCommand::Delete(c) => c.execute(context),
BoardCommand::ApplySettings(c) => c.execute(context),
BoardCommand::Import(c) => c.execute(context),
BoardCommand::RestoreSprintPool(c) => c.execute(context),
}
}
pub fn description(&self) -> String {
match self {
BoardCommand::Create(c) => c.description(),
BoardCommand::Update(c) => c.description(),
BoardCommand::SetTaskSort(c) => c.description(),
BoardCommand::SetTaskListView(c) => c.description(),
BoardCommand::Delete(c) => c.description(),
BoardCommand::ApplySettings(c) => c.description(),
BoardCommand::Import(c) => c.description(),
BoardCommand::RestoreSprintPool(c) => c.description(),
}
}
pub fn capture_inverse(&self, store: &dyn DataStore) -> KanbanResult<Vec<Command>> {
match self {
BoardCommand::Create(c) => c.capture_inverse(store),
BoardCommand::Update(c) => c.capture_inverse(store),
BoardCommand::SetTaskSort(c) => c.capture_inverse(store),
BoardCommand::SetTaskListView(c) => c.capture_inverse(store),
BoardCommand::ApplySettings(c) => c.capture_inverse(store),
BoardCommand::Delete(c) => c.capture_inverse(store),
BoardCommand::Import(c) => c.capture_inverse(store),
BoardCommand::RestoreSprintPool(c) => c.capture_inverse(store),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RestoreSprintPool {
pub board_id: Uuid,
pub sprint_names: Vec<String>,
pub sprint_name_used_count: usize,
}
impl RestoreSprintPool {
pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {
let mut board = context.get_board(self.board_id)?;
board.sprint_names = self.sprint_names.clone();
board.sprint_name_used_count = self.sprint_name_used_count;
context.store.upsert_board(board)?;
Ok(())
}
pub fn description(&self) -> String {
format!("Restore sprint-name pool for board {}", self.board_id)
}
pub fn capture_inverse(&self, _store: &dyn DataStore) -> KanbanResult<Vec<Command>> {
Err(KanbanError::Internal(format!(
"RestoreSprintPool is a synthetic command — it must only appear inside an inverse batch (UpdateSprint undo), never as a top-level forward command. Board id: {}",
self.board_id
)))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateBoard {
pub id: Uuid,
pub name: String,
pub card_prefix: Option<String>,
#[serde(default)]
pub position: i32,
}
impl CreateBoard {
pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {
let mut board = Board::new(self.name.clone(), self.card_prefix.clone());
board.id = self.id;
board.position = self.position;
context.store.upsert_board(board)?;
Ok(())
}
pub fn description(&self) -> String {
format!("Create board: '{}'", self.name)
}
pub fn capture_inverse(&self, _store: &dyn DataStore) -> KanbanResult<Vec<Command>> {
Ok(vec![Command::Board(BoardCommand::Delete(DeleteBoard {
board_id: self.id,
}))])
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateBoard {
pub board_id: Uuid,
pub updates: crate::BoardUpdate,
}
impl UpdateBoard {
pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {
let mut board = context.get_board(self.board_id)?;
if !matches!(self.updates.card_prefix, FieldUpdate::NoChange) && board.card_counter > 1 {
return Err(KanbanError::validation(
"board card_prefix cannot be changed after cards have been created",
));
}
board.update(self.updates.clone());
context.store.upsert_board(board)?;
Ok(())
}
pub fn description(&self) -> String {
"Update board".to_string()
}
pub fn capture_inverse(&self, store: &dyn DataStore) -> KanbanResult<Vec<Command>> {
let board = match store.get_board(self.board_id)? {
Some(b) => b,
None => return Err(KanbanError::not_found("Board", self.board_id)),
};
let upd = &self.updates;
let inverse = crate::BoardUpdate {
name: upd.name.as_ref().map(|_| board.name.clone()),
description: match upd.description {
FieldUpdate::NoChange => FieldUpdate::NoChange,
_ => match board.description {
Some(v) => FieldUpdate::Set(v),
None => FieldUpdate::Clear,
},
},
sprint_prefix: match upd.sprint_prefix {
FieldUpdate::NoChange => FieldUpdate::NoChange,
_ => match board.sprint_prefix {
Some(v) => FieldUpdate::Set(v),
None => FieldUpdate::Clear,
},
},
card_prefix: match upd.card_prefix {
FieldUpdate::NoChange => FieldUpdate::NoChange,
_ => match board.card_prefix {
Some(v) => FieldUpdate::Set(v),
None => FieldUpdate::Clear,
},
},
task_sort_field: upd.task_sort_field.map(|_| board.task_sort_field),
task_sort_order: upd.task_sort_order.map(|_| board.task_sort_order),
sprint_duration_days: match upd.sprint_duration_days {
FieldUpdate::NoChange => FieldUpdate::NoChange,
_ => match board.sprint_duration_days {
Some(v) => FieldUpdate::Set(v),
None => FieldUpdate::Clear,
},
},
task_list_view: upd.task_list_view.map(|_| board.task_list_view),
active_sprint_id: match upd.active_sprint_id {
FieldUpdate::NoChange => FieldUpdate::NoChange,
_ => match board.active_sprint_id {
Some(v) => FieldUpdate::Set(v),
None => FieldUpdate::Clear,
},
},
completion_column_id: match upd.completion_column_id {
FieldUpdate::NoChange => FieldUpdate::NoChange,
_ => match board.completion_column_id {
Some(v) => FieldUpdate::Set(v),
None => FieldUpdate::Clear,
},
},
position: upd.position.map(|_| board.position),
};
Ok(vec![Command::Board(BoardCommand::Update(UpdateBoard {
board_id: self.board_id,
updates: inverse,
}))])
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetBoardTaskSort {
pub board_id: Uuid,
pub field: crate::SortField,
pub order: crate::SortOrder,
}
impl SetBoardTaskSort {
pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {
let mut board = context.get_board(self.board_id)?;
board.update_task_sort(self.field, self.order);
context.store.upsert_board(board)?;
Ok(())
}
pub fn description(&self) -> String {
format!("Set board task sort to {:?} {:?}", self.field, self.order)
}
pub fn capture_inverse(&self, store: &dyn DataStore) -> KanbanResult<Vec<Command>> {
let board = match store.get_board(self.board_id)? {
Some(b) => b,
None => return Err(KanbanError::not_found("Board", self.board_id)),
};
Ok(vec![Command::Board(BoardCommand::SetTaskSort(
SetBoardTaskSort {
board_id: self.board_id,
field: board.task_sort_field,
order: board.task_sort_order,
},
))])
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetBoardTaskListView {
pub board_id: Uuid,
pub view: crate::TaskListView,
}
impl SetBoardTaskListView {
pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {
let mut board = context.get_board(self.board_id)?;
board.update_task_list_view(self.view);
context.store.upsert_board(board)?;
Ok(())
}
pub fn description(&self) -> String {
format!("Set board task list view to {:?}", self.view)
}
pub fn capture_inverse(&self, store: &dyn DataStore) -> KanbanResult<Vec<Command>> {
let board = match store.get_board(self.board_id)? {
Some(b) => b,
None => return Err(KanbanError::not_found("Board", self.board_id)),
};
Ok(vec![Command::Board(BoardCommand::SetTaskListView(
SetBoardTaskListView {
board_id: self.board_id,
view: board.task_list_view,
},
))])
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteBoard {
pub board_id: Uuid,
}
impl DeleteBoard {
pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {
context.store.delete_board(self.board_id)
}
pub fn description(&self) -> String {
format!("Delete board: {}", self.board_id)
}
pub fn capture_inverse(&self, store: &dyn DataStore) -> KanbanResult<Vec<Command>> {
let board = match store.get_board(self.board_id)? {
Some(b) => b,
None => return Err(KanbanError::not_found("Board", self.board_id)),
};
Ok(vec![Command::Board(BoardCommand::Import(ImportEntities {
boards: vec![board],
..Default::default()
}))])
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApplyBoardSettings {
pub board_id: Uuid,
pub dto: crate::editable::BoardSettingsDto,
}
impl ApplyBoardSettings {
pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {
let mut board = context.get_board(self.board_id)?;
self.dto.clone().apply_to(&mut board);
context.store.upsert_board(board)?;
Ok(())
}
pub fn description(&self) -> String {
format!("Apply board settings for {}", self.board_id)
}
pub fn capture_inverse(&self, store: &dyn DataStore) -> KanbanResult<Vec<Command>> {
let board = match store.get_board(self.board_id)? {
Some(b) => b,
None => return Err(KanbanError::not_found("Board", self.board_id)),
};
let prior_dto = crate::editable::BoardSettingsDto::from_entity(&board);
Ok(vec![Command::Board(BoardCommand::ApplySettings(
ApplyBoardSettings {
board_id: self.board_id,
dto: prior_dto,
},
))])
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ImportEntities {
pub boards: Vec<Board>,
pub columns: Vec<Column>,
pub cards: Vec<Card>,
pub archived_cards: Vec<ArchivedCard>,
pub sprints: Vec<Sprint>,
pub graph: Option<DependencyGraph>,
}
impl ImportEntities {
pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {
use std::collections::HashSet;
let existing_board_ids: HashSet<Uuid> =
context.store.list_boards()?.iter().map(|b| b.id).collect();
let existing_column_ids: HashSet<Uuid> = context
.store
.list_all_columns()?
.iter()
.map(|c| c.id)
.collect();
let existing_card_ids: HashSet<Uuid> = context
.store
.list_all_cards()?
.iter()
.map(|c| c.id)
.collect();
let existing_sprint_ids: HashSet<Uuid> = context
.store
.list_all_sprints()?
.iter()
.map(|s| s.id)
.collect();
let existing_archived_ids: HashSet<Uuid> = context
.store
.list_archived_cards()?
.iter()
.map(|ac| ac.card.id)
.collect();
for b in &self.boards {
if existing_board_ids.contains(&b.id) {
return Err(crate::KanbanError::validation(format!(
"Duplicate board ID: {}",
b.id
)));
}
}
for c in &self.columns {
if existing_column_ids.contains(&c.id) {
return Err(crate::KanbanError::validation(format!(
"Duplicate column ID: {}",
c.id
)));
}
}
for c in &self.cards {
if existing_card_ids.contains(&c.id) {
return Err(crate::KanbanError::validation(format!(
"Duplicate card ID: {}",
c.id
)));
}
}
for ac in &self.archived_cards {
if existing_archived_ids.contains(&ac.card.id) {
return Err(crate::KanbanError::validation(format!(
"Duplicate archived card ID: {}",
ac.card.id
)));
}
}
for s in &self.sprints {
if existing_sprint_ids.contains(&s.id) {
return Err(crate::KanbanError::validation(format!(
"Duplicate sprint ID: {}",
s.id
)));
}
}
for b in &self.boards {
context.store.upsert_board(b.clone())?;
}
for c in &self.columns {
context.store.upsert_column(c.clone())?;
}
for c in &self.cards {
context.store.upsert_card(c.clone())?;
}
for ac in &self.archived_cards {
context.store.insert_archived_card(ac.clone())?;
}
for s in &self.sprints {
context.store.upsert_sprint(s.clone())?;
}
if let Some(ref graph) = self.graph {
context.store.set_graph(graph.clone())?;
}
Ok(())
}
pub fn description(&self) -> String {
format!("Import {} board(s)", self.boards.len())
}
pub fn capture_inverse(&self, _store: &dyn DataStore) -> KanbanResult<Vec<Command>> {
let mut commands: Vec<Command> = Vec::new();
if !self.cards.is_empty() {
commands.push(Command::Card(crate::commands::CardCommand::Archive(
crate::commands::ArchiveCards {
ids: self.cards.iter().map(|c| c.id).collect(),
},
)));
}
for ac in &self.archived_cards {
commands.push(Command::Card(crate::commands::CardCommand::Delete(
crate::commands::DeleteCard {
card_id: ac.card.id,
},
)));
}
for s in &self.sprints {
commands.push(Command::Sprint(crate::commands::SprintCommand::Delete(
crate::commands::DeleteSprint {
sprint_id: s.id,
timestamp: chrono::Utc::now(),
},
)));
}
for c in &self.columns {
commands.push(Command::Column(crate::commands::ColumnCommand::Delete(
crate::commands::DeleteColumn { column_id: c.id },
)));
}
for b in &self.boards {
commands.push(Command::Board(BoardCommand::Delete(DeleteBoard {
board_id: b.id,
})));
}
Ok(commands)
}
}
#[cfg(test)]
mod tests {
use super::super::test_helpers::TestContext;
use super::*;
use crate::DataStore;
#[test]
fn test_update_board_not_found_returns_error() {
let tc = TestContext::new();
let context = tc.as_command_context();
let cmd = UpdateBoard {
board_id: Uuid::new_v4(),
updates: crate::BoardUpdate::default(),
};
let result = cmd.execute(&context);
assert!(result.unwrap_err().is_not_found());
}
#[test]
fn test_set_board_task_sort_not_found_returns_error() {
let tc = TestContext::new();
let context = tc.as_command_context();
let cmd = SetBoardTaskSort {
board_id: Uuid::new_v4(),
field: crate::SortField::Priority,
order: crate::SortOrder::Ascending,
};
let result = cmd.execute(&context);
assert!(result.unwrap_err().is_not_found());
}
#[test]
fn test_set_board_task_list_view_not_found_returns_error() {
let tc = TestContext::new();
let context = tc.as_command_context();
let cmd = SetBoardTaskListView {
board_id: Uuid::new_v4(),
view: crate::TaskListView::default(),
};
let result = cmd.execute(&context);
assert!(result.unwrap_err().is_not_found());
}
#[test]
fn test_import_entities_with_duplicate_board_id_returns_error() {
let tc = TestContext::new();
let b1 = Board::new("B1", None::<String>);
let dup_id = b1.id;
tc.store.upsert_board(b1).unwrap();
let mut dup = Board::new("Dup", None::<String>);
dup.id = dup_id;
let cmd = ImportEntities {
boards: vec![dup],
columns: vec![],
cards: vec![],
archived_cards: vec![],
sprints: vec![],
graph: None,
};
let context = tc.as_command_context();
let result = cmd.execute(&context);
assert!(result.is_err());
assert!(result.unwrap_err().is_validation());
}
#[test]
fn test_import_entities_with_duplicate_card_id_returns_error() {
let tc = TestContext::new();
let mut board = Board::new("B", Some("TST"));
let col = crate::Column::new(board.id, "Col", 0);
let card = crate::Card::new(&mut board, col.id, "Card", 0);
let dup_card_id = card.id;
tc.store.upsert_board(board.clone()).unwrap();
tc.store.upsert_column(col).unwrap();
tc.store.upsert_card(card).unwrap();
let mut dup_card = crate::Card::new(&mut board, Uuid::new_v4(), "Dup", 0);
dup_card.id = dup_card_id;
let cmd = ImportEntities {
boards: vec![],
columns: vec![],
cards: vec![dup_card],
archived_cards: vec![],
sprints: vec![],
graph: None,
};
let context = tc.as_command_context();
let result = cmd.execute(&context);
assert!(result.is_err());
assert!(result.unwrap_err().is_validation());
}
#[test]
fn test_import_entities_appends_without_replacing() {
let tc = TestContext::new();
let b1 = Board::new("B1", None::<String>);
tc.store.upsert_board(b1).unwrap();
let b2 = Board::new("B2", None::<String>);
let col = crate::Column::new(b2.id, "Todo", 0);
let mut b2_clone = b2.clone();
let card = crate::Card::new(&mut b2_clone, col.id, "Card", 0);
let cmd = ImportEntities {
boards: vec![b2],
columns: vec![col],
cards: vec![card],
archived_cards: vec![],
sprints: vec![],
graph: None,
};
let context = tc.as_command_context();
cmd.execute(&context).unwrap();
let boards = tc.store.list_boards().unwrap();
assert_eq!(boards.len(), 2);
assert!(boards.iter().any(|b| b.name == "B1"));
assert!(boards.iter().any(|b| b.name == "B2"));
assert_eq!(tc.store.list_all_columns().unwrap().len(), 1);
assert_eq!(tc.store.list_all_cards().unwrap().len(), 1);
}
#[test]
fn test_update_board_card_prefix_allowed_before_first_card_succeeds() {
let tc = TestContext::new();
let board = Board::new("B", Some("OLD"));
let board_id = board.id;
tc.store.upsert_board(board).unwrap();
let context = tc.as_command_context();
let cmd = UpdateBoard {
board_id,
updates: crate::BoardUpdate {
card_prefix: FieldUpdate::Set("NEW".to_string()),
..Default::default()
},
};
assert!(cmd.execute(&context).is_ok());
let board = tc.store.get_board(board_id).unwrap().unwrap();
assert_eq!(board.card_prefix, Some("NEW".to_string()));
}
#[test]
fn test_update_board_card_prefix_locked_after_first_card_returns_validation_error() {
let tc = TestContext::new();
let mut board = Board::new("B", Some("OLD"));
let board_id = board.id;
let col = Column::new(board_id, "Col", 0);
let _card = Card::new(&mut board, col.id, "C", 0);
tc.store.upsert_board(board).unwrap();
tc.store.upsert_column(col).unwrap();
let context = tc.as_command_context();
let cmd = UpdateBoard {
board_id,
updates: crate::BoardUpdate {
card_prefix: FieldUpdate::Set("NEW".to_string()),
..Default::default()
},
};
let err = cmd.execute(&context).unwrap_err();
assert!(err.is_validation());
}
#[test]
fn test_update_board_clear_card_prefix_locked_after_first_card_returns_validation_error() {
let tc = TestContext::new();
let mut board = Board::new("B", Some("OLD"));
let board_id = board.id;
let col = Column::new(board_id, "Col", 0);
let _card = Card::new(&mut board, col.id, "C", 0);
tc.store.upsert_board(board).unwrap();
tc.store.upsert_column(col).unwrap();
let context = tc.as_command_context();
let cmd = UpdateBoard {
board_id,
updates: crate::BoardUpdate {
card_prefix: FieldUpdate::Clear,
..Default::default()
},
};
let err = cmd.execute(&context).unwrap_err();
assert!(err.is_validation());
}
#[test]
fn test_delete_board_atomic_removes_only_board_record() {
let tc = TestContext::new();
let board = Board::new("B", Some("TST"));
let board_id = board.id;
let col = Column::new(board_id, "Col", 0);
tc.store.upsert_board(board).unwrap();
tc.store.upsert_column(col.clone()).unwrap();
let context = tc.as_command_context();
let cmd = DeleteBoard { board_id };
cmd.execute(&context).unwrap();
assert!(tc.store.list_boards().unwrap().is_empty());
assert_eq!(
tc.store.list_all_columns().unwrap().len(),
1,
"atomic DeleteBoard must not cascade to columns; cascade is the service's responsibility"
);
}
}