use chrono::{DateTime, Utc};
use serde::{Deserialize, Deserializer, Serialize};
use std::collections::HashMap;
use uuid::Uuid;
use crate::field_update::FieldUpdate;
use crate::task_list_view::TaskListView;
pub type BoardId = Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SortField {
Points,
Priority,
CreatedAt,
UpdatedAt,
Status,
Position,
Default,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SortOrder {
Ascending,
Descending,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Board {
pub id: BoardId,
pub name: String,
pub description: Option<String>,
#[serde(default, alias = "branch_prefix")]
pub sprint_prefix: Option<String>,
#[serde(default)]
pub card_prefix: Option<String>,
#[serde(default = "default_sort_field")]
pub task_sort_field: SortField,
#[serde(default = "default_sort_order")]
pub task_sort_order: SortOrder,
#[serde(default)]
pub sprint_duration_days: Option<u32>,
#[serde(default)]
pub sprint_names: Vec<String>,
#[serde(default)]
pub sprint_name_used_count: usize,
#[serde(default = "default_next_sprint_number")]
pub next_sprint_number: u32,
#[serde(default)]
pub active_sprint_id: Option<Uuid>,
#[serde(default)]
pub task_list_view: TaskListView,
#[serde(default)]
pub card_counter: u32,
#[serde(default)]
pub sprint_counters: HashMap<String, u32>,
#[serde(default)]
pub completion_column_id: Option<Uuid>,
#[serde(default)]
pub position: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl<'de> Deserialize<'de> for Board {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
struct BoardHelper {
pub id: BoardId,
pub name: String,
pub description: Option<String>,
#[serde(default)]
pub sprint_prefix: Option<String>,
#[serde(default)]
pub branch_prefix: Option<String>,
#[serde(default)]
pub card_prefix: Option<String>,
#[serde(default = "default_sort_field")]
pub task_sort_field: SortField,
#[serde(default = "default_sort_order")]
pub task_sort_order: SortOrder,
#[serde(default)]
pub sprint_duration_days: Option<u32>,
#[serde(default)]
pub sprint_names: Vec<String>,
#[serde(default)]
pub sprint_name_used_count: usize,
#[serde(default = "default_next_sprint_number")]
pub next_sprint_number: u32,
#[serde(default)]
pub active_sprint_id: Option<Uuid>,
#[serde(default)]
pub task_list_view: TaskListView,
#[serde(default)]
pub card_counter: u32,
#[serde(default)]
pub prefix_counters: HashMap<String, u32>,
#[serde(default)]
pub sprint_counters: HashMap<String, u32>,
#[serde(default)]
pub completion_column_id: Option<Uuid>,
#[serde(default)]
pub position: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(default)]
pub next_card_number: u32,
}
let helper = BoardHelper::deserialize(deserializer)?;
let sprint_prefix = helper.sprint_prefix.or(helper.branch_prefix);
let card_counter = if helper.card_counter > 0 {
helper.card_counter
} else if !helper.prefix_counters.is_empty() {
let matching_key = helper.card_prefix.as_deref().unwrap_or("task");
helper
.prefix_counters
.get(matching_key)
.copied()
.unwrap_or_else(|| helper.prefix_counters.values().copied().max().unwrap_or(1))
} else if helper.next_card_number > 1 {
helper.next_card_number
} else {
1
};
let board = Board {
id: helper.id,
name: helper.name,
description: helper.description,
sprint_prefix,
card_prefix: helper.card_prefix,
task_sort_field: helper.task_sort_field,
task_sort_order: helper.task_sort_order,
sprint_duration_days: helper.sprint_duration_days,
sprint_names: helper.sprint_names,
sprint_name_used_count: helper.sprint_name_used_count,
next_sprint_number: helper.next_sprint_number,
active_sprint_id: helper.active_sprint_id,
task_list_view: helper.task_list_view,
card_counter,
sprint_counters: helper.sprint_counters,
completion_column_id: helper.completion_column_id,
position: helper.position,
created_at: helper.created_at,
updated_at: helper.updated_at,
};
Ok(board)
}
}
fn default_next_sprint_number() -> u32 {
1
}
fn default_sort_field() -> SortField {
SortField::Default
}
fn default_sort_order() -> SortOrder {
SortOrder::Ascending
}
impl Board {
pub fn new(name: String, card_prefix: Option<String>) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4(),
name,
description: None,
sprint_prefix: None,
card_prefix,
task_sort_field: SortField::Default,
task_sort_order: SortOrder::Ascending,
sprint_duration_days: None,
sprint_names: Vec::new(),
sprint_name_used_count: 0,
next_sprint_number: 1,
active_sprint_id: None,
task_list_view: TaskListView::default(),
card_counter: 1,
sprint_counters: HashMap::new(),
completion_column_id: None,
position: 0,
created_at: now,
updated_at: now,
}
}
pub fn update_name(&mut self, name: String) {
self.name = name;
self.updated_at = Utc::now();
}
pub fn update_description(&mut self, description: Option<String>) {
self.description = description;
self.updated_at = Utc::now();
}
pub fn update_sprint_prefix(&mut self, prefix: Option<String>) {
self.sprint_prefix = prefix;
self.updated_at = Utc::now();
}
pub fn update_card_prefix(&mut self, prefix: Option<String>) {
self.card_prefix = prefix;
self.updated_at = Utc::now();
}
pub fn effective_sprint_prefix<'a>(&'a self, default_prefix: &'a str) -> &'a str {
self.sprint_prefix.as_deref().unwrap_or(default_prefix)
}
pub fn effective_card_prefix<'a>(&'a self, default_prefix: &'a str) -> &'a str {
self.card_prefix.as_deref().unwrap_or(default_prefix)
}
pub fn effective_branch_prefix<'a>(&'a self, default_prefix: &'a str) -> &'a str {
self.effective_sprint_prefix(default_prefix)
}
pub fn update_task_sort(&mut self, field: SortField, order: SortOrder) {
self.task_sort_field = field;
self.task_sort_order = order;
self.updated_at = Utc::now();
}
pub fn allocate_sprint_number(&mut self) -> u32 {
let number = self.next_sprint_number;
self.next_sprint_number += 1;
self.updated_at = Utc::now();
number
}
pub fn consume_sprint_name(&mut self) -> Option<usize> {
if self.sprint_name_used_count < self.sprint_names.len() {
let index = self.sprint_name_used_count;
self.sprint_name_used_count += 1;
self.updated_at = Utc::now();
Some(index)
} else {
None
}
}
pub fn add_sprint_name_at_used_index(&mut self, name: String) -> usize {
if self.sprint_name_used_count > self.sprint_names.len() {
self.sprint_name_used_count = self.sprint_names.len();
}
let index = self.sprint_name_used_count;
self.sprint_names.insert(index, name);
self.sprint_name_used_count += 1;
self.updated_at = Utc::now();
index
}
pub fn update_task_list_view(&mut self, view: TaskListView) {
self.task_list_view = view;
self.updated_at = Utc::now();
}
pub fn get_next_card_number(&mut self) -> u32 {
let number = self.card_counter;
self.card_counter += 1;
self.updated_at = Utc::now();
number
}
pub fn initialize_card_counter(&mut self, start: u32) {
self.card_counter = start;
self.updated_at = Utc::now();
}
pub fn get_card_counter(&self) -> u32 {
self.card_counter
}
pub fn get_next_sprint_number(&mut self, prefix: &str) -> u32 {
let counter = self.sprint_counters.entry(prefix.to_string()).or_insert(1);
let number = *counter;
*counter += 1;
self.updated_at = Utc::now();
number
}
pub fn initialize_sprint_counter(&mut self, prefix: &str, start: u32) {
self.sprint_counters.insert(prefix.to_string(), start);
self.updated_at = Utc::now();
}
pub fn get_sprint_counters(&self) -> &HashMap<String, u32> {
&self.sprint_counters
}
pub fn get_sprint_counter(&self, prefix: &str) -> Option<u32> {
self.sprint_counters.get(prefix).copied()
}
pub fn resolve_completion_column(&self, columns: &[crate::Column]) -> Option<Uuid> {
if let Some(id) = self.completion_column_id {
if columns.iter().any(|c| c.id == id && c.board_id == self.id) {
return Some(id);
}
}
columns
.iter()
.filter(|c| c.board_id == self.id)
.max_by_key(|c| c.position)
.map(|c| c.id)
}
pub fn update_completion_column_id(&mut self, column_id: Option<Uuid>) {
self.completion_column_id = column_id;
self.updated_at = Utc::now();
}
pub fn ensure_sprint_counter_initialized(
&mut self,
prefix: &str,
all_sprints: &[crate::Sprint],
) {
if self.sprint_counters.contains_key(prefix) {
return;
}
let max_number = all_sprints
.iter()
.filter(|sprint| {
if sprint.board_id != self.id {
return false;
}
let sprint_prefix = sprint
.prefix
.as_deref()
.unwrap_or_else(|| self.sprint_prefix.as_deref().unwrap_or("sprint"));
sprint_prefix == prefix
})
.map(|sprint| sprint.sprint_number)
.max()
.unwrap_or(0);
let next_number = max_number + 1;
self.initialize_sprint_counter(prefix, next_number);
}
pub fn update(&mut self, updates: BoardUpdate) {
if let Some(name) = updates.name {
self.name = name;
}
updates.description.apply_to(&mut self.description);
updates.sprint_prefix.apply_to(&mut self.sprint_prefix);
updates.card_prefix.apply_to(&mut self.card_prefix);
if let Some(task_sort_field) = updates.task_sort_field {
self.task_sort_field = task_sort_field;
}
if let Some(task_sort_order) = updates.task_sort_order {
self.task_sort_order = task_sort_order;
}
updates
.sprint_duration_days
.apply_to(&mut self.sprint_duration_days);
if let Some(task_list_view) = updates.task_list_view {
self.task_list_view = task_list_view;
}
updates
.active_sprint_id
.apply_to(&mut self.active_sprint_id);
updates
.completion_column_id
.apply_to(&mut self.completion_column_id);
if let Some(position) = updates.position {
self.position = position;
}
self.updated_at = Utc::now();
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct BoardUpdate {
pub name: Option<String>,
pub description: FieldUpdate<String>,
pub sprint_prefix: FieldUpdate<String>,
pub card_prefix: FieldUpdate<String>,
pub task_sort_field: Option<SortField>,
pub task_sort_order: Option<SortOrder>,
pub sprint_duration_days: FieldUpdate<u32>,
pub task_list_view: Option<TaskListView>,
pub active_sprint_id: FieldUpdate<Uuid>,
pub completion_column_id: FieldUpdate<Uuid>,
pub position: Option<i32>,
}
pub fn get_active_sprint_card_prefix_override<'a>(
board: &'a Board,
sprints: &'a [crate::Sprint],
) -> Option<&'a str> {
board.active_sprint_id.and_then(|sprint_id| {
sprints
.iter()
.find(|s| s.id == sprint_id)
.and_then(|sprint| sprint.card_prefix.as_deref())
})
}
pub fn get_active_sprint_prefix_override<'a>(
board: &'a Board,
sprints: &'a [crate::Sprint],
) -> Option<&'a str> {
board.active_sprint_id.and_then(|sprint_id| {
sprints
.iter()
.find(|s| s.id == sprint_id)
.and_then(|sprint| sprint.prefix.as_deref())
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_board_new_card_counter_initialized_to_one() {
let board = Board::new("Test".to_string(), None);
assert_eq!(board.card_counter, 1);
}
#[test]
fn test_board_get_next_card_number_increments_without_prefix() {
let mut board = Board::new("Test".to_string(), None);
assert_eq!(board.get_next_card_number(), 1);
assert_eq!(board.get_next_card_number(), 2);
assert_eq!(board.get_next_card_number(), 3);
assert_eq!(board.card_counter, 4);
}
#[test]
fn test_board_initialize_card_counter_sets_value_and_get_next_returns_it() {
let mut board = Board::new("Test".to_string(), None);
board.initialize_card_counter(10);
assert_eq!(board.get_card_counter(), 10);
assert_eq!(board.get_next_card_number(), 10);
assert_eq!(board.get_card_counter(), 11);
}
#[test]
fn test_deserialization_migrates_prefix_counters_to_card_counter() {
let json = r#"{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Test Board",
"description": null,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"sprint_prefix": null,
"card_prefix": "feat",
"task_sort_field": "Default",
"task_sort_order": "Ascending",
"active_sprint_id": null,
"sprint_duration_days": null,
"sprint_names": [],
"next_sprint_number": 1,
"sprint_name_used_count": 0,
"prefix_counters": {"feat": 42, "other": 5},
"sprint_counters": {},
"task_list_view": "Flat"
}"#;
let board: Board = serde_json::from_str(json).expect("Should deserialize");
assert_eq!(
board.card_counter, 42,
"Should pick the matching prefix counter"
);
}
#[test]
fn test_deserialization_migrates_next_card_number_to_card_counter() {
let json = r#"{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Test Board",
"description": null,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"sprint_prefix": null,
"card_prefix": null,
"task_sort_field": "Default",
"task_sort_order": "Ascending",
"active_sprint_id": null,
"sprint_duration_days": null,
"sprint_names": [],
"next_sprint_number": 1,
"sprint_name_used_count": 0,
"prefix_counters": {},
"sprint_counters": {},
"task_list_view": "Flat",
"next_card_number": 42
}"#;
let board: Board = serde_json::from_str(json).expect("Should deserialize");
assert_eq!(board.card_counter, 42);
}
#[test]
fn test_deserialization_card_counter_takes_priority_over_prefix_counters() {
let json = r#"{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Test Board",
"description": null,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"sprint_prefix": null,
"card_prefix": null,
"task_sort_field": "Default",
"task_sort_order": "Ascending",
"active_sprint_id": null,
"sprint_duration_days": null,
"sprint_names": [],
"next_sprint_number": 1,
"sprint_name_used_count": 0,
"card_counter": 100,
"prefix_counters": {"task": 50},
"sprint_counters": {},
"task_list_view": "Flat"
}"#;
let board: Board = serde_json::from_str(json).expect("Should deserialize");
assert_eq!(board.card_counter, 100, "card_counter takes priority");
}
#[test]
fn test_deserialization_prefix_counters_uses_max_when_no_prefix_match() {
let json = r#"{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Test Board",
"description": null,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"sprint_prefix": null,
"card_prefix": null,
"task_sort_field": "Default",
"task_sort_order": "Ascending",
"active_sprint_id": null,
"sprint_duration_days": null,
"sprint_names": [],
"next_sprint_number": 1,
"sprint_name_used_count": 0,
"prefix_counters": {"FEAT": 20, "BUG": 30},
"sprint_counters": {},
"task_list_view": "Flat"
}"#;
let board: Board = serde_json::from_str(json).expect("Should deserialize");
assert_eq!(board.card_counter, 30, "Falls back to max of all counters");
}
#[test]
fn test_update_sprint_prefix() {
let mut board = Board::new("Test".to_string(), None);
assert_eq!(board.sprint_prefix, None);
board.update_sprint_prefix(Some("feat".to_string()));
assert_eq!(board.sprint_prefix, Some("feat".to_string()));
board.update_sprint_prefix(None);
assert_eq!(board.sprint_prefix, None);
}
#[test]
fn test_update_card_prefix() {
let mut board = Board::new("Test".to_string(), None);
assert_eq!(board.card_prefix, None);
board.update_card_prefix(Some("task".to_string()));
assert_eq!(board.card_prefix, Some("task".to_string()));
board.update_card_prefix(None);
assert_eq!(board.card_prefix, None);
}
#[test]
fn test_effective_sprint_prefix() {
let mut board = Board::new("Test".to_string(), None);
assert_eq!(board.effective_sprint_prefix("default"), "default");
board.update_sprint_prefix(Some("custom".to_string()));
assert_eq!(board.effective_sprint_prefix("default"), "custom");
}
#[test]
fn test_effective_card_prefix() {
let mut board = Board::new("Test".to_string(), None);
assert_eq!(board.effective_card_prefix("task"), "task");
board.update_card_prefix(Some("feature".to_string()));
assert_eq!(board.effective_card_prefix("task"), "feature");
}
#[test]
fn test_effective_branch_prefix_backward_compat() {
let mut board = Board::new("Test".to_string(), None);
board.update_sprint_prefix(Some("custom".to_string()));
assert_eq!(board.effective_branch_prefix("default"), "custom");
}
#[test]
fn test_resolve_completion_column_fallback() {
let board = Board::new("Test".to_string(), None);
let col1 = crate::Column::new(board.id, "Todo".to_string(), 0);
let col2 = crate::Column::new(board.id, "In Progress".to_string(), 1);
let col3 = crate::Column::new(board.id, "Done".to_string(), 2);
let columns = vec![col1, col2, col3.clone()];
assert_eq!(board.resolve_completion_column(&columns), Some(col3.id));
}
#[test]
fn test_resolve_completion_column_explicit() {
let mut board = Board::new("Test".to_string(), None);
let col1 = crate::Column::new(board.id, "Todo".to_string(), 0);
let col2 = crate::Column::new(board.id, "Done".to_string(), 1);
let col3 = crate::Column::new(board.id, "Archive".to_string(), 2);
let columns = vec![col1, col2.clone(), col3];
board.update_completion_column_id(Some(col2.id));
assert_eq!(board.resolve_completion_column(&columns), Some(col2.id));
}
#[test]
fn test_resolve_completion_column_stale_id_falls_back() {
let mut board = Board::new("Test".to_string(), None);
let col1 = crate::Column::new(board.id, "Todo".to_string(), 0);
let col2 = crate::Column::new(board.id, "Done".to_string(), 1);
let columns = vec![col1, col2.clone()];
board.update_completion_column_id(Some(Uuid::new_v4()));
assert_eq!(board.resolve_completion_column(&columns), Some(col2.id));
}
#[test]
fn test_resolve_completion_column_empty_columns() {
let board = Board::new("Test".to_string(), None);
assert_eq!(board.resolve_completion_column(&[]), None);
}
#[test]
fn test_sprint_counter_initialization() {
let mut board = Board::new("Test".to_string(), None);
assert_eq!(board.get_sprint_counter("sprint"), None);
let num = board.get_next_sprint_number("sprint");
assert_eq!(num, 1);
assert_eq!(board.get_sprint_counter("sprint"), Some(2));
}
#[test]
fn test_shared_sprint_sequence() {
let mut board = Board::new("Test".to_string(), None);
let num1 = board.get_next_sprint_number("SPRINT");
assert_eq!(num1, 1);
let num2 = board.get_next_sprint_number("SPRINT");
assert_eq!(num2, 2);
let num3 = board.get_next_sprint_number("SPRINT");
assert_eq!(num3, 3);
assert_eq!(board.get_sprint_counter("SPRINT"), Some(4));
}
#[test]
fn test_initialize_sprint_counter() {
let mut board = Board::new("Test".to_string(), None);
board.initialize_sprint_counter("RELEASE", 5);
let num = board.get_next_sprint_number("RELEASE");
assert_eq!(num, 5);
assert_eq!(board.get_sprint_counter("RELEASE"), Some(6));
}
#[test]
fn test_ensure_sprint_counter_initialized_with_existing_sprints() {
use crate::Sprint;
let mut board = Board::new("Test".to_string(), None);
let sprint1 = Sprint::new(board.id, 1, None, None);
let sprint2 = Sprint::new(board.id, 2, None, None);
let sprint3 = Sprint::new(board.id, 3, None, None);
let sprints = vec![sprint1, sprint2, sprint3];
assert_eq!(board.get_sprint_counter("sprint"), None);
board.ensure_sprint_counter_initialized("sprint", &sprints);
assert_eq!(board.get_sprint_counter("sprint"), Some(4));
let next = board.get_next_sprint_number("sprint");
assert_eq!(next, 4);
}
#[test]
fn test_ensure_sprint_counter_not_reinitialize() {
use crate::Sprint;
let mut board = Board::new("Test".to_string(), None);
board.initialize_sprint_counter("test", 10);
let sprint1 = Sprint::new(board.id, 1, None, Some("test".to_string()));
let sprint2 = Sprint::new(board.id, 2, None, Some("test".to_string()));
let sprints = vec![sprint1, sprint2];
board.ensure_sprint_counter_initialized("test", &sprints);
assert_eq!(board.get_sprint_counter("test"), Some(10));
}
}
#[cfg(test)]
mod sort_field_serialization_tests {
use super::*;
#[test]
fn test_position_serializes_correctly() {
let field = SortField::Position;
let json = serde_json::to_string(&field).unwrap();
assert_eq!(json, "\"Position\"");
}
#[test]
fn test_position_deserializes_correctly() {
let json = "\"Position\"";
let field: SortField = serde_json::from_str(json).unwrap();
assert_eq!(field, SortField::Position);
}
#[test]
fn test_board_with_position_sort_serializes() {
let mut board = Board::new("Test".to_string(), None);
board.update_task_sort(SortField::Position, SortOrder::Descending);
let json = serde_json::to_string(&board).unwrap();
assert!(
json.contains("\"task_sort_field\":\"Position\""),
"Expected Position in JSON, got: {}",
json
);
assert!(
json.contains("\"task_sort_order\":\"Descending\""),
"Expected Descending in JSON, got: {}",
json
);
}
}