diff --git a/.changeset/kan-193-bring-mcp-to-full-feature-parity-with-cli-tui-via-kanbanoperations-trait.md b/.changeset/kan-193-bring-mcp-to-full-feature-parity-with-cli-tui-via-kanbanoperations-trait.md new file mode 100644 index 00000000..7326af4e --- /dev/null +++ b/.changeset/kan-193-bring-mcp-to-full-feature-parity-with-cli-tui-via-kanbanoperations-trait.md @@ -0,0 +1,21 @@ +--- +bump: patch +--- + +- test: add integration tests for MCP round-trips +- test: add unit tests for MCP helpers and ArgsBuilder +- feat: update MCP server tools for full CLI parity +- feat: bring MCP context to full parity with CLI +- fix: error handling in MCP executor +- feat: add sprint update fields to CLI (name, dates, clear flags) +- feat: add --clear-wip-limit flag to CLI column update +- feat: rewrite MCP server with 37 tools via KanbanOperations trait +- feat: remove McpTools trait, replaced by KanbanOperations from kanban-domain +- feat: add McpContext implementing KanbanOperations trait +- feat: replace async CliExecutor with sync SyncExecutor +- feat: add kanban-domain, kanban-core, uuid, chrono, tempfile deps to kanban-mcp +- fix: remove create_card_full bypass, use trait two-step create+update pattern +- fix: remove update_sprint_full bypass, route through trait's update_sprint +- feat: add name field to SprintUpdate for MCP name passthrough +- fix: remove broken clear_description and clear_points MCP flags +- refactor: remove 4 dead pre-animation functions from TUI card_handlers diff --git a/Cargo.lock b/Cargo.lock index 15cd35ca..4e2a3594 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -970,13 +970,19 @@ version = "0.2.0" dependencies = [ "anyhow", "async-trait", + "chrono", + "kanban-core", + "kanban-domain", + "parking_lot", "rmcp", "schemars", "serde", "serde_json", + "tempfile", "tokio", "tracing", "tracing-subscriber", + "uuid", ] [[package]] diff --git a/README.md b/README.md index 5d717ab7..04a916fb 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,14 @@ kanban myboard.json # Load a board from file 3. Add cards with `n` and organize them 4. Press `x` to export as JSON +### MCP Server + +```bash +nix run github:fulsomenko/kanban#kanban-mcp +``` + +Provides full read/write access to your boards, cards, columns, and sprints over the [Model Context Protocol](https://modelcontextprotocol.io) for use with LLM tools like Claude Code, Cursor, etc. + ### CLI ```bash diff --git a/crates/kanban-cli/src/cli.rs b/crates/kanban-cli/src/cli.rs index 4cc46492..a11d827f 100644 --- a/crates/kanban-cli/src/cli.rs +++ b/crates/kanban-cli/src/cli.rs @@ -141,6 +141,8 @@ pub struct ColumnUpdateArgs { pub position: Option, #[arg(long)] pub wip_limit: Option, + #[arg(long)] + pub clear_wip_limit: bool, } // Card commands @@ -343,9 +345,19 @@ pub struct SprintUpdateArgs { /// Sprint ID to update pub id: Uuid, #[arg(long)] + pub name: Option, + #[arg(long)] pub prefix: Option, #[arg(long)] pub card_prefix: Option, + #[arg(long)] + pub start_date: Option, + #[arg(long)] + pub end_date: Option, + #[arg(long)] + pub clear_start_date: bool, + #[arg(long)] + pub clear_end_date: bool, } // Export/Import commands diff --git a/crates/kanban-cli/src/context.rs b/crates/kanban-cli/src/context.rs index 338baee0..dbb001a7 100644 --- a/crates/kanban-cli/src/context.rs +++ b/crates/kanban-cli/src/context.rs @@ -276,6 +276,7 @@ impl KanbanOperations for CliContext { board_id: Uuid, column_id: Uuid, title: String, + options: kanban_domain::CreateCardOptions, ) -> KanbanResult { use kanban_domain::commands::CreateCard; let position = self @@ -288,6 +289,7 @@ impl KanbanOperations for CliContext { column_id, title, position, + options, }; self.execute(Box::new(cmd))?; self.cards.last().cloned().ok_or_else(|| { diff --git a/crates/kanban-cli/src/handlers/card.rs b/crates/kanban-cli/src/handlers/card.rs index 2375f5de..3661c2dd 100644 --- a/crates/kanban-cli/src/handlers/card.rs +++ b/crates/kanban-cli/src/handlers/card.rs @@ -2,26 +2,15 @@ use crate::cli::{CardAction, CardCreateArgs, CardListArgs, CardUpdateArgs}; use crate::context::CliContext; use crate::output; use kanban_domain::{ - CardListFilter, CardPriority, CardStatus, CardSummary, CardUpdate, FieldUpdate, - KanbanOperations, + CardListFilter, CardPriority, CardStatus, CardSummary, CardUpdate, CreateCardOptions, + FieldUpdate, KanbanOperations, }; pub async fn handle(ctx: &mut CliContext, action: CardAction) -> anyhow::Result<()> { match action { CardAction::Create(args) => { - let title = args.title.clone(); - let mut card = ctx.create_card(args.board_id, args.column_id, title)?; - - if args.description.is_some() - || args.priority.is_some() - || args.points.is_some() - || args.due_date.is_some() - { - let updates = - build_card_update_from_create(&args).map_err(|e| anyhow::anyhow!(e))?; - card = ctx.update_card(card.id, updates)?; - } - + let options = build_create_options(&args).map_err(|e| anyhow::anyhow!(e))?; + let card = ctx.create_card(args.board_id, args.column_id, args.title, options)?; ctx.save().await?; output::output_success(&card); } @@ -135,33 +124,20 @@ fn build_filter(args: &CardListArgs) -> Result { }) } -fn build_card_update_from_create(args: &CardCreateArgs) -> Result { +fn build_create_options(args: &CardCreateArgs) -> Result { let priority = match &args.priority { Some(p) => Some(parse_priority(p)?), None => None, }; - Ok(CardUpdate { - title: None, - description: args - .description - .clone() - .map(FieldUpdate::Set) - .unwrap_or(FieldUpdate::NoChange), + let due_date = match &args.due_date { + Some(d) => Some(parse_datetime(d)?), + None => None, + }; + Ok(CreateCardOptions { + description: args.description.clone(), priority, - status: None, - position: None, - column_id: None, - points: args - .points - .map(FieldUpdate::Set) - .unwrap_or(FieldUpdate::NoChange), - due_date: match &args.due_date { - Some(d) => FieldUpdate::Set(parse_datetime(d)?), - None => FieldUpdate::NoChange, - }, - sprint_id: FieldUpdate::NoChange, - assigned_prefix: FieldUpdate::NoChange, - card_prefix: FieldUpdate::NoChange, + points: args.points, + due_date, }) } diff --git a/crates/kanban-cli/src/handlers/column.rs b/crates/kanban-cli/src/handlers/column.rs index d4a84d95..12d108b5 100644 --- a/crates/kanban-cli/src/handlers/column.rs +++ b/crates/kanban-cli/src/handlers/column.rs @@ -47,11 +47,14 @@ async fn handle_update( let updates = ColumnUpdate { name: args.name, position: args.position, - wip_limit: args - .wip_limit - .map(|w| w as i32) - .map(FieldUpdate::Set) - .unwrap_or(FieldUpdate::NoChange), + wip_limit: if args.clear_wip_limit { + FieldUpdate::Clear + } else { + args.wip_limit + .map(|w| w as i32) + .map(FieldUpdate::Set) + .unwrap_or(FieldUpdate::NoChange) + }, }; let column = ctx.update_column(args.id, updates)?; ctx.save().await?; diff --git a/crates/kanban-cli/src/handlers/sprint.rs b/crates/kanban-cli/src/handlers/sprint.rs index 4bcf041e..0febf092 100644 --- a/crates/kanban-cli/src/handlers/sprint.rs +++ b/crates/kanban-cli/src/handlers/sprint.rs @@ -3,6 +3,23 @@ use crate::context::CliContext; use crate::output; use kanban_domain::{FieldUpdate, KanbanOperations, SprintUpdate}; +fn parse_datetime(s: &str) -> Result, String> { + chrono::DateTime::parse_from_rfc3339(s) + .map(|dt| dt.with_timezone(&chrono::Utc)) + .or_else(|_| { + chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") + .map_err(|_| ()) + .and_then(|d| d.and_hms_opt(0, 0, 0).ok_or(())) + .map(|dt| dt.and_utc()) + }) + .map_err(|_| { + format!( + "Invalid date '{}'. Supported formats: YYYY-MM-DD or RFC 3339 (e.g., 2024-01-15T10:30:00Z)", + s + ) + }) +} + pub async fn handle(ctx: &mut CliContext, action: SprintAction) -> anyhow::Result<()> { match action { SprintAction::Create { @@ -54,7 +71,26 @@ async fn handle_update( ctx: &mut CliContext, args: SprintUpdateArgs, ) -> anyhow::Result { + let start_date = if args.clear_start_date { + FieldUpdate::Clear + } else { + match args.start_date { + Some(d) => FieldUpdate::Set(parse_datetime(&d).map_err(anyhow::Error::msg)?), + None => FieldUpdate::NoChange, + } + }; + + let end_date = if args.clear_end_date { + FieldUpdate::Clear + } else { + match args.end_date { + Some(d) => FieldUpdate::Set(parse_datetime(&d).map_err(anyhow::Error::msg)?), + None => FieldUpdate::NoChange, + } + }; + let updates = SprintUpdate { + name: args.name, name_index: FieldUpdate::NoChange, prefix: args .prefix @@ -65,8 +101,8 @@ async fn handle_update( .map(FieldUpdate::Set) .unwrap_or(FieldUpdate::NoChange), status: None, - start_date: FieldUpdate::NoChange, - end_date: FieldUpdate::NoChange, + start_date, + end_date, }; let sprint = ctx.update_sprint(args.id, updates)?; ctx.save().await?; diff --git a/crates/kanban-domain/src/card.rs b/crates/kanban-domain/src/card.rs index 379c288a..cc9af536 100644 --- a/crates/kanban-domain/src/card.rs +++ b/crates/kanban-domain/src/card.rs @@ -1,5 +1,6 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use std::fmt; use uuid::Uuid; use crate::{board::Board, column::ColumnId, field_update::FieldUpdate, sprint::Sprint, SprintLog}; @@ -23,6 +24,28 @@ pub enum CardStatus { Done, } +impl fmt::Display for CardPriority { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Low => write!(f, "low"), + Self::Medium => write!(f, "medium"), + Self::High => write!(f, "high"), + Self::Critical => write!(f, "critical"), + } + } +} + +impl fmt::Display for CardStatus { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Todo => write!(f, "todo"), + Self::InProgress => write!(f, "in_progress"), + Self::Blocked => write!(f, "blocked"), + Self::Done => write!(f, "done"), + } + } +} + /// Represents card lifecycle operation types. /// Used for visual feedback during card operations. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -331,6 +354,14 @@ pub struct CardUpdate { pub card_prefix: FieldUpdate, } +#[derive(Debug, Clone, Default)] +pub struct CreateCardOptions { + pub description: Option, + pub priority: Option, + pub points: Option, + pub due_date: Option>, +} + impl GraphNode for Card { fn node_id(&self) -> Uuid { self.id diff --git a/crates/kanban-domain/src/commands/card_commands.rs b/crates/kanban-domain/src/commands/card_commands.rs index 53a43cb1..66438094 100644 --- a/crates/kanban-domain/src/commands/card_commands.rs +++ b/crates/kanban-domain/src/commands/card_commands.rs @@ -1,6 +1,6 @@ use super::{Command, CommandContext}; use crate::dependencies::card_graph::CardGraphExt; -use crate::CardUpdate; +use crate::{CardUpdate, CreateCardOptions}; use chrono::Utc; use kanban_core::KanbanResult; use uuid::Uuid; @@ -30,6 +30,7 @@ pub struct CreateCard { pub column_id: Uuid, pub title: String, pub position: i32, + pub options: CreateCardOptions, } impl Command for CreateCard { @@ -53,6 +54,37 @@ impl Command for CreateCard { &prefix, ); context.cards.push(card); + + // Apply optional fields + if self.options.description.is_some() + || self.options.priority.is_some() + || self.options.points.is_some() + || self.options.due_date.is_some() + { + if let Some(card) = context.cards.last_mut() { + let updates = CardUpdate { + description: self + .options + .description + .clone() + .map(crate::FieldUpdate::Set) + .unwrap_or(crate::FieldUpdate::NoChange), + priority: self.options.priority, + points: self + .options + .points + .map(crate::FieldUpdate::Set) + .unwrap_or(crate::FieldUpdate::NoChange), + due_date: self + .options + .due_date + .map(crate::FieldUpdate::Set) + .unwrap_or(crate::FieldUpdate::NoChange), + ..Default::default() + }; + card.update(updates); + } + } } Ok(()) } diff --git a/crates/kanban-domain/src/commands/sprint_commands.rs b/crates/kanban-domain/src/commands/sprint_commands.rs index 1929257e..e49271c5 100644 --- a/crates/kanban-domain/src/commands/sprint_commands.rs +++ b/crates/kanban-domain/src/commands/sprint_commands.rs @@ -12,8 +12,25 @@ pub struct UpdateSprint { impl Command for UpdateSprint { fn execute(&self, context: &mut CommandContext) -> KanbanResult<()> { + let mut updates = self.updates.clone(); + + if let Some(ref name) = updates.name { + let board_id = context + .sprints + .iter() + .find(|s| s.id == self.sprint_id) + .map(|s| s.board_id); + + if let Some(board_id) = board_id { + if let Some(board) = context.boards.iter_mut().find(|b| b.id == board_id) { + let idx = board.add_sprint_name_at_used_index(name.clone()); + updates.name_index = crate::FieldUpdate::Set(idx); + } + } + } + if let Some(sprint) = context.sprints.iter_mut().find(|s| s.id == self.sprint_id) { - sprint.update(self.updates.clone()); + sprint.update(updates); } Ok(()) } diff --git a/crates/kanban-domain/src/lib.rs b/crates/kanban-domain/src/lib.rs index dce23e2b..c99b59ff 100644 --- a/crates/kanban-domain/src/lib.rs +++ b/crates/kanban-domain/src/lib.rs @@ -25,7 +25,10 @@ pub use board::{ get_active_sprint_card_prefix_override, get_active_sprint_prefix_override, Board, BoardId, BoardUpdate, SortField, SortOrder, }; -pub use card::{AnimationType, Card, CardId, CardPriority, CardStatus, CardSummary, CardUpdate}; +pub use card::{ + AnimationType, Card, CardId, CardPriority, CardStatus, CardSummary, CardUpdate, + CreateCardOptions, +}; pub use column::{Column, ColumnId, ColumnUpdate}; pub use dependencies::{CardDependencyGraph, CardEdgeType, CardGraphExt, DependencyGraph}; pub use editable::{BoardSettingsDto, CardMetadataDto}; diff --git a/crates/kanban-domain/src/operations.rs b/crates/kanban-domain/src/operations.rs index 160a658e..f0cb6ead 100644 --- a/crates/kanban-domain/src/operations.rs +++ b/crates/kanban-domain/src/operations.rs @@ -1,6 +1,6 @@ use crate::{ - ArchivedCard, Board, BoardUpdate, Card, CardStatus, CardUpdate, Column, ColumnUpdate, Sprint, - SprintUpdate, + ArchivedCard, Board, BoardUpdate, Card, CardStatus, CardUpdate, Column, ColumnUpdate, + CreateCardOptions, Sprint, SprintUpdate, }; use kanban_core::KanbanResult; use uuid::Uuid; @@ -38,8 +38,13 @@ pub trait KanbanOperations { fn reorder_column(&mut self, id: Uuid, new_position: i32) -> KanbanResult; // Card operations - fn create_card(&mut self, board_id: Uuid, column_id: Uuid, title: String) - -> KanbanResult; + fn create_card( + &mut self, + board_id: Uuid, + column_id: Uuid, + title: String, + options: CreateCardOptions, + ) -> KanbanResult; fn list_cards(&self, filter: CardListFilter) -> KanbanResult>; fn get_card(&self, id: Uuid) -> KanbanResult>; fn update_card(&mut self, id: Uuid, updates: CardUpdate) -> KanbanResult; diff --git a/crates/kanban-domain/src/sprint.rs b/crates/kanban-domain/src/sprint.rs index 7e184313..b43bdbb5 100644 --- a/crates/kanban-domain/src/sprint.rs +++ b/crates/kanban-domain/src/sprint.rs @@ -167,6 +167,7 @@ impl Sprint { /// See [`FieldUpdate`] documentation for usage examples. #[derive(Debug, Clone, Default)] pub struct SprintUpdate { + pub name: Option, pub name_index: FieldUpdate, pub prefix: FieldUpdate, pub card_prefix: FieldUpdate, diff --git a/crates/kanban-mcp/Cargo.toml b/crates/kanban-mcp/Cargo.toml index 96f1e075..df74247b 100644 --- a/crates/kanban-mcp/Cargo.toml +++ b/crates/kanban-mcp/Cargo.toml @@ -15,6 +15,10 @@ name = "kanban-mcp" path = "src/main.rs" [dependencies] +# Workspace crates +kanban-core = { path = "../kanban-core", version = "^0.2" } +kanban-domain = { path = "../kanban-domain", version = "^0.2" } + # MCP SDK rmcp = { version = "0.11", features = ["server", "transport-io"] } schemars = "1.0" @@ -27,6 +31,16 @@ async-trait.workspace = true serde.workspace = true serde_json.workspace = true +# UUID and time +uuid.workspace = true +chrono.workspace = true + +# Synchronization (non-poisoning mutex) +parking_lot = "0.12" + +# Temp files (for import) +tempfile.workspace = true + # Error handling (for main.rs only) anyhow.workspace = true diff --git a/crates/kanban-mcp/default.nix b/crates/kanban-mcp/default.nix index e6e919ba..96892f29 100644 --- a/crates/kanban-mcp/default.nix +++ b/crates/kanban-mcp/default.nix @@ -18,11 +18,15 @@ rustPlatform.buildRustPackage { }; nativeBuildInputs = [ makeWrapper ]; + nativeCheckInputs = [ kanban ]; # Only build the kanban-mcp binary cargoBuildFlags = [ "--package" "kanban-mcp" ]; cargoTestFlags = [ "--package" "kanban-mcp" ]; + # Point integration tests to the Nix-built kanban binary + KANBAN_BIN = lib.getExe kanban; + # Wrap the binary to include kanban CLI in PATH postInstall = '' wrapProgram $out/bin/kanban-mcp \ diff --git a/crates/kanban-mcp/src/context.rs b/crates/kanban-mcp/src/context.rs new file mode 100644 index 00000000..b3a6311e --- /dev/null +++ b/crates/kanban-mcp/src/context.rs @@ -0,0 +1,648 @@ +use crate::executor::SyncExecutor; +use kanban_core::KanbanResult; +use kanban_domain::{ + ArchivedCard, Board, BoardUpdate, Card, CardListFilter, CardUpdate, Column, ColumnUpdate, + CreateCardOptions, FieldUpdate, KanbanOperations, Sprint, SprintUpdate, +}; +use uuid::Uuid; + +#[derive(serde::Deserialize)] +struct ListResponse { + items: Vec, +} + +#[derive(serde::Deserialize)] +struct DeletedResponse { + #[allow(dead_code)] + deleted: String, +} + +#[derive(serde::Deserialize)] +struct ArchivedResponse { + #[allow(dead_code)] + archived: String, +} + +#[derive(serde::Deserialize)] +struct BulkResponse { + succeeded_count: usize, +} + +#[derive(serde::Deserialize)] +struct BranchNameResponse { + branch_name: String, +} + +#[derive(serde::Deserialize)] +struct GitCheckoutResponse { + command: String, +} + +struct ArgsBuilder { + args: Vec, +} + +impl ArgsBuilder { + fn new(base: &[&str]) -> Self { + Self { + args: base.iter().map(|s| s.to_string()).collect(), + } + } + + fn add_opt(&mut self, flag: &str, value: Option<&str>) -> &mut Self { + if let Some(v) = value { + self.args.push(flag.to_string()); + self.args.push(v.to_string()); + } + self + } + + fn add_opt_num(&mut self, flag: &str, value: Option) -> &mut Self { + if let Some(v) = value { + self.args.push(flag.to_string()); + self.args.push(v.to_string()); + } + self + } + + fn add_flag(&mut self, flag: &str, value: bool) -> &mut Self { + if value { + self.args.push(flag.to_string()); + } + self + } + + fn add_field_str(&mut self, flag: &str, field: &FieldUpdate) -> &mut Self { + if let FieldUpdate::Set(v) = field { + self.args.push(flag.to_string()); + self.args.push(v.clone()); + } + self + } + + fn build(&self) -> Vec<&str> { + self.args.iter().map(|s| s.as_str()).collect() + } +} + +pub struct McpContext { + executor: SyncExecutor, +} + +impl McpContext { + pub fn new(data_file: &str) -> Self { + Self { + executor: SyncExecutor::new(data_file.to_string()), + } + } + + pub fn with_kanban_path(mut self, path: &str) -> Self { + self.executor = self.executor.with_kanban_path(path.to_string()); + self + } + + fn execute_get( + &self, + args: &[&str], + ) -> KanbanResult> { + match self.executor.execute::(args) { + Ok(val) => Ok(Some(val)), + Err(kanban_core::KanbanError::NotFound(_)) => Ok(None), + Err(e) => Err(e), + } + } + + fn execute_list(&self, args: &[&str]) -> KanbanResult> { + let response: ListResponse = self.executor.execute(args)?; + Ok(response.items) + } +} + +impl KanbanOperations for McpContext { + // ======================================================================== + // Board Operations + // ======================================================================== + + fn create_board(&mut self, name: String, card_prefix: Option) -> KanbanResult { + let mut builder = ArgsBuilder::new(&["board", "create", "--name", &name]); + builder.add_opt("--card-prefix", card_prefix.as_deref()); + self.executor.execute_with_retry(&builder.build()) + } + + fn list_boards(&self) -> KanbanResult> { + self.execute_list(&["board", "list"]) + } + + fn get_board(&self, id: Uuid) -> KanbanResult> { + let id_str = id.to_string(); + self.execute_get(&["board", "get", &id_str]) + } + + fn update_board(&mut self, id: Uuid, updates: BoardUpdate) -> KanbanResult { + let id_str = id.to_string(); + let mut builder = ArgsBuilder::new(&["board", "update", &id_str]); + builder + .add_opt("--name", updates.name.as_deref()) + .add_field_str("--description", &updates.description) + .add_field_str("--sprint-prefix", &updates.sprint_prefix) + .add_field_str("--card-prefix", &updates.card_prefix); + self.executor.execute_with_retry(&builder.build()) + } + + fn delete_board(&mut self, id: Uuid) -> KanbanResult<()> { + let id_str = id.to_string(); + let _: DeletedResponse = self + .executor + .execute_with_retry(&["board", "delete", &id_str])?; + Ok(()) + } + + // ======================================================================== + // Column Operations + // ======================================================================== + + fn create_column( + &mut self, + board_id: Uuid, + name: String, + position: Option, + ) -> KanbanResult { + let board_id_str = board_id.to_string(); + let mut builder = ArgsBuilder::new(&[ + "column", + "create", + "--board-id", + &board_id_str, + "--name", + &name, + ]); + builder.add_opt_num("--position", position); + self.executor.execute_with_retry(&builder.build()) + } + + fn list_columns(&self, board_id: Uuid) -> KanbanResult> { + let board_id_str = board_id.to_string(); + self.execute_list(&["column", "list", "--board-id", &board_id_str]) + } + + fn get_column(&self, id: Uuid) -> KanbanResult> { + let id_str = id.to_string(); + self.execute_get(&["column", "get", &id_str]) + } + + fn update_column(&mut self, id: Uuid, updates: ColumnUpdate) -> KanbanResult { + let id_str = id.to_string(); + let mut builder = ArgsBuilder::new(&["column", "update", &id_str]); + builder + .add_opt("--name", updates.name.as_deref()) + .add_opt_num("--position", updates.position); + match updates.wip_limit { + FieldUpdate::Set(wip) => { + builder.add_opt_num("--wip-limit", Some(wip)); + } + FieldUpdate::Clear => { + builder.add_flag("--clear-wip-limit", true); + } + _ => {} + } + self.executor.execute_with_retry(&builder.build()) + } + + fn delete_column(&mut self, id: Uuid) -> KanbanResult<()> { + let id_str = id.to_string(); + let _: DeletedResponse = self + .executor + .execute_with_retry(&["column", "delete", &id_str])?; + Ok(()) + } + + fn reorder_column(&mut self, id: Uuid, new_position: i32) -> KanbanResult { + let id_str = id.to_string(); + let pos_str = new_position.to_string(); + self.executor + .execute_with_retry(&["column", "reorder", &id_str, "--position", &pos_str]) + } + + // ======================================================================== + // Card Operations + // ======================================================================== + + fn create_card( + &mut self, + board_id: Uuid, + column_id: Uuid, + title: String, + options: CreateCardOptions, + ) -> KanbanResult { + let board_id_str = board_id.to_string(); + let column_id_str = column_id.to_string(); + let priority_str = options.priority.map(|p| p.to_string()); + let points_str = options.points.map(|p| p.to_string()); + let due_date_str = options.due_date.map(|d| d.to_rfc3339()); + + let mut builder = ArgsBuilder::new(&["card", "create"]); + builder + .add_opt("--board-id", Some(&board_id_str)) + .add_opt("--column-id", Some(&column_id_str)) + .add_opt("--title", Some(&title)) + .add_opt("--description", options.description.as_deref()) + .add_opt("--priority", priority_str.as_deref()) + .add_opt("--points", points_str.as_deref()) + .add_opt("--due-date", due_date_str.as_deref()); + self.executor.execute_with_retry(&builder.build()) + } + + fn list_cards(&self, filter: CardListFilter) -> KanbanResult> { + let board_id_str = filter.board_id.map(|id| id.to_string()); + let column_id_str = filter.column_id.map(|id| id.to_string()); + let sprint_id_str = filter.sprint_id.map(|id| id.to_string()); + let status_str = filter.status.map(|s| s.to_string()); + + let mut builder = ArgsBuilder::new(&["card", "list"]); + builder + .add_opt("--board-id", board_id_str.as_deref()) + .add_opt("--column-id", column_id_str.as_deref()) + .add_opt("--sprint-id", sprint_id_str.as_deref()) + .add_opt("--status", status_str.as_deref()); + self.execute_list(&builder.build()) + } + + fn get_card(&self, id: Uuid) -> KanbanResult> { + let id_str = id.to_string(); + self.execute_get(&["card", "get", &id_str]) + } + + fn update_card(&mut self, id: Uuid, updates: CardUpdate) -> KanbanResult { + let id_str = id.to_string(); + let mut builder = ArgsBuilder::new(&["card", "update", &id_str]); + builder.add_opt("--title", updates.title.as_deref()); + + if let FieldUpdate::Set(v) = &updates.description { + builder.add_opt("--description", Some(v.as_str())); + } + + if let Some(p) = &updates.priority { + let p_str = p.to_string(); + builder.add_opt("--priority", Some(&p_str)); + } + if let Some(s) = &updates.status { + let s_str = s.to_string(); + builder.add_opt("--status", Some(&s_str)); + } + + if let FieldUpdate::Set(v) = &updates.points { + builder.add_opt_num("--points", Some(*v)); + } + + match &updates.due_date { + FieldUpdate::Set(v) => { + let date_str = v.to_rfc3339(); + builder.add_opt("--due-date", Some(&date_str)); + } + FieldUpdate::Clear => { + builder.add_flag("--clear-due-date", true); + } + _ => {} + } + + self.executor.execute_with_retry(&builder.build()) + } + + fn move_card( + &mut self, + id: Uuid, + column_id: Uuid, + position: Option, + ) -> KanbanResult { + let id_str = id.to_string(); + let column_id_str = column_id.to_string(); + let mut builder = + ArgsBuilder::new(&["card", "move", &id_str, "--column-id", &column_id_str]); + builder.add_opt_num("--position", position); + self.executor.execute_with_retry(&builder.build()) + } + + fn archive_card(&mut self, id: Uuid) -> KanbanResult<()> { + let id_str = id.to_string(); + let _: ArchivedResponse = self + .executor + .execute_with_retry(&["card", "archive", &id_str])?; + Ok(()) + } + + fn restore_card(&mut self, id: Uuid, column_id: Option) -> KanbanResult { + let id_str = id.to_string(); + let column_id_str = column_id.map(|c| c.to_string()); + let mut builder = ArgsBuilder::new(&["card", "restore", &id_str]); + builder.add_opt("--column-id", column_id_str.as_deref()); + self.executor.execute_with_retry(&builder.build()) + } + + fn delete_card(&mut self, id: Uuid) -> KanbanResult<()> { + let id_str = id.to_string(); + let _: DeletedResponse = self + .executor + .execute_with_retry(&["card", "delete", &id_str])?; + Ok(()) + } + + fn list_archived_cards(&self) -> KanbanResult> { + self.execute_list(&["card", "list", "--archived"]) + } + + // ======================================================================== + // Card Sprint Operations + // ======================================================================== + + fn assign_card_to_sprint(&mut self, card_id: Uuid, sprint_id: Uuid) -> KanbanResult { + let card_id_str = card_id.to_string(); + let sprint_id_str = sprint_id.to_string(); + self.executor.execute_with_retry(&[ + "card", + "assign-sprint", + &card_id_str, + "--sprint-id", + &sprint_id_str, + ]) + } + + fn unassign_card_from_sprint(&mut self, card_id: Uuid) -> KanbanResult { + let card_id_str = card_id.to_string(); + self.executor + .execute_with_retry(&["card", "unassign-sprint", &card_id_str]) + } + + // ======================================================================== + // Card Utilities + // ======================================================================== + + fn get_card_branch_name(&self, id: Uuid) -> KanbanResult { + let id_str = id.to_string(); + let resp: BranchNameResponse = self.executor.execute(&["card", "branch-name", &id_str])?; + Ok(resp.branch_name) + } + + fn get_card_git_checkout(&self, id: Uuid) -> KanbanResult { + let id_str = id.to_string(); + let resp: GitCheckoutResponse = + self.executor.execute(&["card", "git-checkout", &id_str])?; + Ok(resp.command) + } + + // ======================================================================== + // Bulk Card Operations + // ======================================================================== + + fn bulk_archive_cards(&mut self, ids: Vec) -> KanbanResult { + let ids_csv: String = ids + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(","); + let resp: BulkResponse = + self.executor + .execute_with_retry(&["card", "bulk-archive", "--ids", &ids_csv])?; + Ok(resp.succeeded_count) + } + + fn bulk_move_cards(&mut self, ids: Vec, column_id: Uuid) -> KanbanResult { + let ids_csv: String = ids + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(","); + let column_id_str = column_id.to_string(); + let resp: BulkResponse = self.executor.execute_with_retry(&[ + "card", + "bulk-move", + "--ids", + &ids_csv, + "--column-id", + &column_id_str, + ])?; + Ok(resp.succeeded_count) + } + + fn bulk_assign_sprint(&mut self, ids: Vec, sprint_id: Uuid) -> KanbanResult { + let ids_csv: String = ids + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(","); + let sprint_id_str = sprint_id.to_string(); + let resp: BulkResponse = self.executor.execute_with_retry(&[ + "card", + "bulk-assign-sprint", + "--ids", + &ids_csv, + "--sprint-id", + &sprint_id_str, + ])?; + Ok(resp.succeeded_count) + } + + // ======================================================================== + // Sprint Operations + // ======================================================================== + + fn create_sprint( + &mut self, + board_id: Uuid, + prefix: Option, + name: Option, + ) -> KanbanResult { + let board_id_str = board_id.to_string(); + let mut builder = ArgsBuilder::new(&["sprint", "create", "--board-id", &board_id_str]); + builder + .add_opt("--prefix", prefix.as_deref()) + .add_opt("--name", name.as_deref()); + self.executor.execute_with_retry(&builder.build()) + } + + fn list_sprints(&self, board_id: Uuid) -> KanbanResult> { + let board_id_str = board_id.to_string(); + self.execute_list(&["sprint", "list", "--board-id", &board_id_str]) + } + + fn get_sprint(&self, id: Uuid) -> KanbanResult> { + let id_str = id.to_string(); + self.execute_get(&["sprint", "get", &id_str]) + } + + fn update_sprint(&mut self, id: Uuid, updates: SprintUpdate) -> KanbanResult { + let id_str = id.to_string(); + let mut builder = ArgsBuilder::new(&["sprint", "update", &id_str]); + builder + .add_opt("--name", updates.name.as_deref()) + .add_field_str("--prefix", &updates.prefix) + .add_field_str("--card-prefix", &updates.card_prefix); + + match &updates.start_date { + FieldUpdate::Set(v) => { + let date_str = v.to_rfc3339(); + builder.add_opt("--start-date", Some(&date_str)); + } + FieldUpdate::Clear => { + builder.add_flag("--clear-start-date", true); + } + _ => {} + } + + match &updates.end_date { + FieldUpdate::Set(v) => { + let date_str = v.to_rfc3339(); + builder.add_opt("--end-date", Some(&date_str)); + } + FieldUpdate::Clear => { + builder.add_flag("--clear-end-date", true); + } + _ => {} + } + + self.executor.execute_with_retry(&builder.build()) + } + + fn activate_sprint(&mut self, id: Uuid, duration_days: Option) -> KanbanResult { + let id_str = id.to_string(); + let mut builder = ArgsBuilder::new(&["sprint", "activate", &id_str]); + builder.add_opt_num("--duration-days", duration_days); + self.executor.execute_with_retry(&builder.build()) + } + + fn complete_sprint(&mut self, id: Uuid) -> KanbanResult { + let id_str = id.to_string(); + self.executor + .execute_with_retry(&["sprint", "complete", &id_str]) + } + + fn cancel_sprint(&mut self, id: Uuid) -> KanbanResult { + let id_str = id.to_string(); + self.executor + .execute_with_retry(&["sprint", "cancel", &id_str]) + } + + fn delete_sprint(&mut self, id: Uuid) -> KanbanResult<()> { + let id_str = id.to_string(); + let _: DeletedResponse = self + .executor + .execute_with_retry(&["sprint", "delete", &id_str])?; + Ok(()) + } + + // ======================================================================== + // Import/Export + // ======================================================================== + + fn export_board(&self, board_id: Option) -> KanbanResult { + let board_id_str = board_id.map(|id| id.to_string()); + let mut builder = ArgsBuilder::new(&["export"]); + builder.add_opt("--board-id", board_id_str.as_deref()); + self.executor.execute_raw_stdout(&builder.build()) + } + + fn import_board(&mut self, data: &str) -> KanbanResult { + let tmp = tempfile::NamedTempFile::new().map_err(|e| { + kanban_core::KanbanError::Internal(format!("Failed to create temp file: {}", e)) + })?; + std::fs::write(tmp.path(), data).map_err(|e| { + kanban_core::KanbanError::Internal(format!("Failed to write temp file: {}", e)) + })?; + let path_str = tmp.path().to_string_lossy().to_string(); + self.executor + .execute_with_retry(&["import", "--file", &path_str]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn args_builder_new() { + let builder = ArgsBuilder::new(&["board", "create"]); + assert_eq!(builder.build(), vec!["board", "create"]); + } + + #[test] + fn args_builder_add_opt_some() { + let mut builder = ArgsBuilder::new(&["card", "create"]); + builder.add_opt("--title", Some("hello")); + assert_eq!(builder.build(), vec!["card", "create", "--title", "hello"]); + } + + #[test] + fn args_builder_add_opt_none() { + let mut builder = ArgsBuilder::new(&["card", "create"]); + builder.add_opt("--title", None); + assert_eq!(builder.build(), vec!["card", "create"]); + } + + #[test] + fn args_builder_add_opt_num_some() { + let mut builder = ArgsBuilder::new(&["card", "create"]); + builder.add_opt_num("--points", Some(5u8)); + assert_eq!(builder.build(), vec!["card", "create", "--points", "5"]); + } + + #[test] + fn args_builder_add_opt_num_none() { + let mut builder = ArgsBuilder::new(&["card", "create"]); + builder.add_opt_num::("--points", None); + assert_eq!(builder.build(), vec!["card", "create"]); + } + + #[test] + fn args_builder_add_flag_true() { + let mut builder = ArgsBuilder::new(&["column", "update"]); + builder.add_flag("--clear-wip-limit", true); + assert_eq!( + builder.build(), + vec!["column", "update", "--clear-wip-limit"] + ); + } + + #[test] + fn args_builder_add_flag_false() { + let mut builder = ArgsBuilder::new(&["column", "update"]); + builder.add_flag("--clear-wip-limit", false); + assert_eq!(builder.build(), vec!["column", "update"]); + } + + #[test] + fn args_builder_add_field_str_set() { + let mut builder = ArgsBuilder::new(&["sprint", "update"]); + let field = FieldUpdate::Set("v1".to_string()); + builder.add_field_str("--prefix", &field); + assert_eq!(builder.build(), vec!["sprint", "update", "--prefix", "v1"]); + } + + #[test] + fn args_builder_add_field_str_clear() { + let mut builder = ArgsBuilder::new(&["sprint", "update"]); + let field: FieldUpdate = FieldUpdate::Clear; + builder.add_field_str("--prefix", &field); + assert_eq!(builder.build(), vec!["sprint", "update"]); + } + + #[test] + fn args_builder_add_field_str_no_change() { + let mut builder = ArgsBuilder::new(&["sprint", "update"]); + let field: FieldUpdate = FieldUpdate::NoChange; + builder.add_field_str("--prefix", &field); + assert_eq!(builder.build(), vec!["sprint", "update"]); + } + + #[test] + fn args_builder_chained() { + let mut builder = ArgsBuilder::new(&["card", "create"]); + builder + .add_opt("--title", Some("test")) + .add_opt("--description", None) + .add_opt_num("--points", Some(3u8)) + .add_flag("--archived", false); + assert_eq!( + builder.build(), + vec!["card", "create", "--title", "test", "--points", "3"] + ); + } +} diff --git a/crates/kanban-mcp/src/executor.rs b/crates/kanban-mcp/src/executor.rs index 9d48b491..bf0247f6 100644 --- a/crates/kanban-mcp/src/executor.rs +++ b/crates/kanban-mcp/src/executor.rs @@ -1,140 +1,165 @@ +use kanban_core::KanbanError; +use kanban_core::KanbanResult; use serde::de::DeserializeOwned; -use std::process::Stdio; -use std::time::Duration; -use tokio::process::Command; +use std::process::Command; +use std::thread; +use std::time::{Duration, Instant}; -use rmcp::model::ErrorData as McpError; - -/// Response format from kanban CLI (matches crates/kanban-cli/src/output.rs) #[derive(Debug, serde::Deserialize)] pub struct CliResponse { pub success: bool, - /// API version from CLI response. Parsed to validate response format; - /// reserved for future version compatibility checks. #[allow(dead_code)] pub api_version: String, pub data: Option, pub error: Option, } -/// Executor that spawns kanban CLI subprocess for each operation -pub struct CliExecutor { +pub struct SyncExecutor { kanban_path: String, data_file: String, } -impl CliExecutor { - /// Create a new executor - /// - /// # Arguments - /// * `data_file` - Path to the kanban.json data file +impl SyncExecutor { + const DEFAULT_RETRY_COUNT: u32 = 3; + const COMMAND_TIMEOUT_SECS: u64 = 30; + pub fn new(data_file: String) -> Self { Self { - kanban_path: "kanban".to_string(), // Assumes in PATH via Nix wrapper + kanban_path: "kanban".to_string(), data_file, } } - /// Execute a kanban CLI command and parse the JSON response - pub async fn execute(&self, args: &[&str]) -> Result { - let output = Command::new(&self.kanban_path) - .arg(&self.data_file) // First arg is always the data file + pub fn with_kanban_path(mut self, path: String) -> Self { + self.kanban_path = path; + self + } + + fn run_with_timeout(&self, args: &[&str]) -> KanbanResult { + let mut child = Command::new(&self.kanban_path) + .arg(&self.data_file) .args(args) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await - .map_err(|e| { - McpError::internal_error(format!("Failed to execute kanban CLI: {}", e), None) - })?; + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| KanbanError::Internal(format!("Failed to execute kanban CLI: {}", e)))?; + + let start = Instant::now(); + let timeout = Duration::from_secs(Self::COMMAND_TIMEOUT_SECS); + + loop { + match child.try_wait() { + Ok(Some(_)) => { + return child.wait_with_output().map_err(|e| { + KanbanError::Internal(format!("Failed to read CLI output: {}", e)) + }); + } + Ok(None) => { + if start.elapsed() > timeout { + let _ = child.kill(); + let _ = child.wait(); + return Err(KanbanError::Internal(format!( + "CLI command timed out after {}s", + Self::COMMAND_TIMEOUT_SECS + ))); + } + thread::sleep(Duration::from_millis(50)); + } + Err(e) => { + return Err(KanbanError::Internal(format!( + "Failed to check CLI process status: {}", + e + ))); + } + } + } + } + + pub fn execute(&self, args: &[&str]) -> KanbanResult { + let output = self.run_with_timeout(args)?; - // Parse stdout as JSON, capture stderr for error messages let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); - let response: CliResponse = serde_json::from_str(&stdout).map_err(|e| { - let error_detail = if stderr.is_empty() { - format!("Failed to parse CLI response: {} (stdout: {})", e, stdout) - } else { - format!( - "Failed to parse CLI response: {} (stdout: {}, stderr: {})", - e, stdout, stderr - ) - }; - McpError::internal_error(error_detail, None) - })?; + let response: CliResponse = serde_json::from_str(&stdout) + .or_else(|_| serde_json::from_str(&stderr)) + .or_else(|_| { + let first_line = stderr.lines().next().unwrap_or(""); + serde_json::from_str(first_line) + }) + .map_err(|e| { + if stderr.is_empty() { + KanbanError::Serialization(format!( + "Failed to parse CLI response: {} (stdout: {})", + e, stdout + )) + } else { + KanbanError::Serialization(format!( + "Failed to parse CLI response: {} (stdout: {}, stderr: {})", + e, stdout, stderr + )) + } + })?; if response.success { - response.data.ok_or_else(|| { - McpError::internal_error("Success response missing data".to_string(), None) - }) + response + .data + .ok_or_else(|| KanbanError::Internal("Success response missing data".to_string())) } else { let error_msg = response .error .unwrap_or_else(|| "Unknown error".to_string()); - // Check for conflict error (retryable) if error_msg.contains("conflict") || error_msg.contains("modified by another") { - Err(McpError::internal_error( - error_msg, - Some(serde_json::json!({"retryable": true})), - )) + Err(KanbanError::ConflictDetected { + path: self.data_file.clone(), + source: None, + }) + } else if error_msg.contains("not found") { + Err(KanbanError::NotFound(error_msg)) } else { - Err(McpError::internal_error(error_msg, None)) + Err(KanbanError::Internal(error_msg)) } } } - /// Execute with retry on conflict (exponential backoff) - pub async fn execute_with_retry( - &self, - args: &[&str], - max_attempts: u32, - ) -> Result { + pub fn execute_with_retry(&self, args: &[&str]) -> KanbanResult { + let max_attempts = Self::DEFAULT_RETRY_COUNT; let mut attempt = 0; let mut delay_ms = 50u64; loop { attempt += 1; - match self.execute(args).await { - Ok(result) => { - if attempt > 1 { - tracing::info!("CLI command succeeded after {} attempts", attempt); - } - return Ok(result); - } - Err(e) if Self::is_retryable(&e) && attempt < max_attempts => { + match self.execute(args) { + Ok(result) => return Ok(result), + Err(KanbanError::ConflictDetected { .. }) if attempt < max_attempts => { tracing::warn!( - "CLI command failed (attempt {}/{}): {}. Retrying after {}ms...", + "CLI command failed (attempt {}/{}): conflict. Retrying after {}ms...", attempt, max_attempts, - e.message, delay_ms ); - tokio::time::sleep(Duration::from_millis(delay_ms)).await; - delay_ms = (delay_ms * 2).min(1000); // Exponential backoff, max 1s - } - Err(e) => { - if attempt > 1 { - tracing::error!( - "CLI command failed after {} attempts: {}", - attempt, - e.message - ); - } - return Err(e); + thread::sleep(Duration::from_millis(delay_ms)); + delay_ms = (delay_ms * 2).min(1000); } + Err(e) => return Err(e), } } } - fn is_retryable(error: &McpError) -> bool { - error - .data - .as_ref() - .and_then(|d| d.get("retryable")) - .and_then(|v| v.as_bool()) - .unwrap_or(false) + pub fn execute_raw_stdout(&self, args: &[&str]) -> KanbanResult { + let output = self.run_with_timeout(args)?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(KanbanError::Internal(format!( + "CLI command failed: {}", + stderr + ))); + } + + String::from_utf8(output.stdout) + .map_err(|e| KanbanError::Internal(format!("Invalid UTF-8 in CLI output: {}", e))) } } diff --git a/crates/kanban-mcp/src/lib.rs b/crates/kanban-mcp/src/lib.rs index 350462f2..1deb4bfb 100644 --- a/crates/kanban-mcp/src/lib.rs +++ b/crates/kanban-mcp/src/lib.rs @@ -1,8 +1,13 @@ -mod executor; -mod tools_trait; - -use async_trait::async_trait; -use executor::CliExecutor; +pub mod context; +pub mod executor; + +use context::McpContext; +use kanban_core::KanbanError; +use kanban_domain::{ + BoardUpdate, CardListFilter, CardPriority, CardStatus, CardUpdate, ColumnUpdate, + CreateCardOptions, FieldUpdate, KanbanOperations, SprintUpdate, +}; +use parking_lot::Mutex; use rmcp::{ handler::server::{router::tool::ToolRouter, wrapper::Parameters}, model::{ @@ -13,12 +18,127 @@ use rmcp::{ }; use serde::Deserialize; use std::sync::Arc; -use tools_trait::{CreateCardParams, McpTools, UpdateCardParams}; +use uuid::Uuid; + +// ============================================================================ +// Helpers +// ============================================================================ + +fn to_call_tool_result(value: &T) -> Result { + let json = serde_json::to_string_pretty(value) + .map_err(|e| McpError::internal_error(format!("Serialization failed: {}", e), None))?; + Ok(CallToolResult::success(vec![Content::text(json)])) +} + +fn to_call_tool_result_json(value: serde_json::Value) -> Result { + let json = serde_json::to_string_pretty(&value) + .map_err(|e| McpError::internal_error(format!("Serialization failed: {}", e), None))?; + Ok(CallToolResult::success(vec![Content::text(json)])) +} + +fn kanban_err_to_mcp(e: KanbanError) -> McpError { + match &e { + KanbanError::NotFound(_) + | KanbanError::Validation(_) + | KanbanError::CycleDetected + | KanbanError::SelfReference + | KanbanError::EdgeNotFound => McpError::invalid_params(e.to_string(), None), + _ => McpError::internal_error(e.to_string(), None), + } +} + +fn parse_uuid(s: &str) -> Result { + Uuid::parse_str(s) + .map_err(|e| McpError::invalid_params(format!("Invalid UUID '{}': {}", s, e), None)) +} + +fn parse_priority(s: &str) -> Result { + match s.to_lowercase().as_str() { + "low" => Ok(CardPriority::Low), + "medium" => Ok(CardPriority::Medium), + "high" => Ok(CardPriority::High), + "critical" => Ok(CardPriority::Critical), + _ => Err(McpError::invalid_params( + format!( + "Invalid priority '{}'. Valid: low, medium, high, critical", + s + ), + None, + )), + } +} + +fn parse_status(s: &str) -> Result { + match s.to_lowercase().replace(['-', '_'], "").as_str() { + "todo" => Ok(CardStatus::Todo), + "inprogress" => Ok(CardStatus::InProgress), + "blocked" => Ok(CardStatus::Blocked), + "done" => Ok(CardStatus::Done), + _ => Err(McpError::invalid_params( + format!( + "Invalid status '{}'. Valid: todo, in_progress, blocked, done", + s + ), + None, + )), + } +} + +fn parse_datetime(s: &str) -> Result, McpError> { + chrono::DateTime::parse_from_rfc3339(s) + .map(|dt| dt.with_timezone(&chrono::Utc)) + .or_else(|_| { + chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") + .map_err(|_| ()) + .and_then(|d| d.and_hms_opt(0, 0, 0).ok_or(())) + .map(|dt| dt.and_utc()) + }) + .map_err(|_| { + McpError::invalid_params( + format!("Invalid date '{}'. Use YYYY-MM-DD or RFC 3339", s), + None, + ) + }) +} + +fn parse_uuids_csv(s: &str) -> Result, McpError> { + s.split(',').map(|id| parse_uuid(id.trim())).collect() +} + +/// Runs a KanbanOperations method on McpContext via spawn_blocking. +macro_rules! spawn_op { + ($ctx:expr, $method:ident $(, $arg:expr)*) => {{ + let ctx = $ctx.clone(); + tokio::task::spawn_blocking(move || { + let mut guard = ctx.lock(); + guard.$method($($arg),*) + }) + .await + .map_err(|e| McpError::internal_error(format!("Task join error: {}", e), None))? + .map_err(kanban_err_to_mcp) + }}; +} + +/// Same as spawn_op but for &self methods (no mutation needed). +macro_rules! spawn_op_ref { + ($ctx:expr, $method:ident $(, $arg:expr)*) => {{ + let ctx = $ctx.clone(); + tokio::task::spawn_blocking(move || { + let guard = ctx.lock(); + guard.$method($($arg),*) + }) + .await + .map_err(|e| McpError::internal_error(format!("Task join error: {}", e), None))? + .map_err(kanban_err_to_mcp) + }}; +} // ============================================================================ -// Request Types (kept for MCP tool schemas) +// Request Types (MCP tool schemas) // ============================================================================ +// Board + #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct CreateBoardRequest { #[schemars(description = "Name of the board")] @@ -27,6 +147,34 @@ pub struct CreateBoardRequest { pub card_prefix: Option, } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct GetBoardRequest { + #[schemars(description = "ID of the board to retrieve")] + pub board_id: String, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct UpdateBoardRequest { + #[schemars(description = "ID of the board to update")] + pub board_id: String, + #[schemars(description = "New name (optional)")] + pub name: Option, + #[schemars(description = "New description (optional)")] + pub description: Option, + #[schemars(description = "New sprint prefix (optional)")] + pub sprint_prefix: Option, + #[schemars(description = "New card prefix (optional)")] + pub card_prefix: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct DeleteBoardRequest { + #[schemars(description = "ID of the board to delete")] + pub board_id: String, +} + +// Column + #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct CreateColumnRequest { #[schemars(description = "ID of the board to create the column in")] @@ -37,6 +185,48 @@ pub struct CreateColumnRequest { pub position: Option, } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct ListColumnsRequest { + #[schemars(description = "ID of the board to list columns for")] + pub board_id: String, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct GetColumnRequest { + #[schemars(description = "ID of the column to retrieve")] + pub column_id: String, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct UpdateColumnRequest { + #[schemars(description = "ID of the column to update")] + pub column_id: String, + #[schemars(description = "New name (optional)")] + pub name: Option, + #[schemars(description = "New position (optional)")] + pub position: Option, + #[schemars(description = "WIP limit (optional)")] + pub wip_limit: Option, + #[schemars(description = "Clear the WIP limit")] + pub clear_wip_limit: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct DeleteColumnRequest { + #[schemars(description = "ID of the column to delete")] + pub column_id: String, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct ReorderColumnRequest { + #[schemars(description = "ID of the column to reorder")] + pub column_id: String, + #[schemars(description = "New position")] + pub position: i32, +} + +// Card + #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct CreateCardRequest { #[schemars(description = "ID of the board")] @@ -51,7 +241,9 @@ pub struct CreateCardRequest { pub priority: Option, #[schemars(description = "Story points (optional, 0-255)")] pub points: Option, - #[schemars(description = "Due date in ISO 8601 format (optional)")] + #[schemars( + description = "Due date in YYYY-MM-DD or RFC 3339 format (e.g. 2024-06-15 or 2024-06-15T10:30:00Z)" + )] pub due_date: Option, } @@ -63,6 +255,8 @@ pub struct ListCardsRequest { pub column_id: Option, #[schemars(description = "Filter cards by sprint ID")] pub sprint_id: Option, + #[schemars(description = "Filter by status: 'todo', 'in_progress', 'blocked', or 'done'")] + pub status: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] @@ -71,58 +265,50 @@ pub struct GetCardRequest { pub card_id: String, } -#[derive(Debug, Deserialize, schemars::JsonSchema)] -pub struct MoveCardRequest { - #[schemars(description = "ID of the card to move")] - pub card_id: String, - #[schemars(description = "ID of the destination column")] - pub column_id: String, - #[schemars(description = "Position in the new column (optional)")] - pub position: Option, -} - #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct UpdateCardRequest { #[schemars(description = "ID of the card to update")] pub card_id: String, #[schemars(description = "New title (optional)")] pub title: Option, - #[schemars(description = "New description (optional, use empty string to clear)")] + #[schemars(description = "New description (optional)")] pub description: Option, - #[schemars(description = "Clear description (set to true to remove description)")] - pub clear_description: Option, #[schemars(description = "Priority: 'low', 'medium', 'high', or 'critical' (optional)")] pub priority: Option, #[schemars(description = "Status: 'todo', 'in_progress', 'blocked', or 'done' (optional)")] pub status: Option, #[schemars( - description = "Due date in ISO 8601 format (optional, use clear_due_date to remove)" + description = "Due date in YYYY-MM-DD or RFC 3339 format (e.g. 2024-06-15 or 2024-06-15T10:30:00Z), use clear_due_date to remove" )] pub due_date: Option, #[schemars(description = "Clear due date (set to true to remove due date)")] pub clear_due_date: Option, #[schemars(description = "Story points (optional, 0-255)")] pub points: Option, - #[schemars(description = "Clear story points (set to true to remove points)")] - pub clear_points: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] -pub struct ListColumnsRequest { - #[schemars(description = "ID of the board to list columns for")] - pub board_id: String, +pub struct MoveCardRequest { + #[schemars(description = "ID of the card to move")] + pub card_id: String, + #[schemars(description = "ID of the destination column")] + pub column_id: String, + #[schemars(description = "Position in the new column (optional)")] + pub position: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] -pub struct DeleteBoardRequest { - #[schemars(description = "ID of the board to delete")] - pub board_id: String, +pub struct ArchiveCardRequest { + #[schemars(description = "ID of the card to archive")] + pub card_id: String, } #[derive(Debug, Deserialize, schemars::JsonSchema)] -pub struct DeleteColumnRequest { - #[schemars(description = "ID of the column to delete")] - pub column_id: String, +pub struct RestoreCardRequest { + #[schemars(description = "ID of the archived card to restore")] + pub card_id: String, + #[schemars(description = "Column ID to restore the card to (optional)")] + pub column_id: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] @@ -131,261 +317,165 @@ pub struct DeleteCardRequest { pub card_id: String, } +// Card Sprint + #[derive(Debug, Deserialize, schemars::JsonSchema)] -pub struct ArchiveCardRequest { - #[schemars(description = "ID of the card to archive")] +pub struct AssignCardToSprintRequest { + #[schemars(description = "ID of the card")] pub card_id: String, + #[schemars(description = "ID of the sprint to assign to")] + pub sprint_id: String, } #[derive(Debug, Deserialize, schemars::JsonSchema)] -pub struct GetBoardRequest { - #[schemars(description = "ID of the board to retrieve")] - pub board_id: String, +pub struct UnassignCardFromSprintRequest { + #[schemars(description = "ID of the card to unassign from its sprint")] + pub card_id: String, } -// ============================================================================ -// MCP Server -// ============================================================================ +// Card Utilities -#[derive(Clone)] -pub struct KanbanMcpServer { - executor: Arc, - tool_router: ToolRouter, +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct GetCardBranchNameRequest { + #[schemars(description = "ID of the card")] + pub card_id: String, } -/// Helper to build CLI args with optional parameters -struct ArgsBuilder { - args: Vec, +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct GetCardGitCheckoutRequest { + #[schemars(description = "ID of the card")] + pub card_id: String, } -impl ArgsBuilder { - fn new(base: &[&str]) -> Self { - Self { - args: base.iter().map(|s| s.to_string()).collect(), - } - } - - fn add_opt(&mut self, flag: &str, value: Option<&str>) -> &mut Self { - if let Some(v) = value { - self.args.push(flag.to_string()); - self.args.push(v.to_string()); - } - self - } - - fn add_opt_num(&mut self, flag: &str, value: Option) -> &mut Self { - if let Some(v) = value { - self.args.push(flag.to_string()); - self.args.push(v.to_string()); - } - self - } +// Bulk Operations - fn add_flag(&mut self, flag: &str, value: Option) -> &mut Self { - if value == Some(true) { - self.args.push(flag.to_string()); - } - self - } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct BulkArchiveCardsRequest { + #[schemars(description = "Comma-separated card IDs to archive")] + pub ids: String, +} - fn build(&self) -> Vec<&str> { - self.args.iter().map(|s| s.as_str()).collect() - } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct BulkMoveCardsRequest { + #[schemars(description = "Comma-separated card IDs to move")] + pub ids: String, + #[schemars(description = "ID of the destination column")] + pub column_id: String, } -/// Convert JSON result to MCP CallToolResult -fn json_result(result: serde_json::Value) -> CallToolResult { - let json_str = serde_json::to_string_pretty(&result) - .unwrap_or_else(|e| format!("{{\"error\": \"Failed to serialize result: {}\"}}", e)); - CallToolResult::success(vec![Content::text(json_str)]) +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct BulkAssignSprintRequest { + #[schemars(description = "Comma-separated card IDs")] + pub ids: String, + #[schemars(description = "ID of the sprint to assign to")] + pub sprint_id: String, } -impl KanbanMcpServer { - const DEFAULT_RETRY_COUNT: u32 = 3; +// Sprint - pub fn new(data_file: &str) -> Self { - Self { - executor: Arc::new(CliExecutor::new(data_file.to_string())), - tool_router: Self::tool_router(), - } - } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct CreateSprintRequest { + #[schemars(description = "ID of the board")] + pub board_id: String, + #[schemars(description = "Sprint prefix (optional)")] + pub prefix: Option, + #[schemars(description = "Sprint name (optional)")] + pub name: Option, } -// ============================================================================ -// McpTools Trait Implementation (business logic) -// ============================================================================ +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct ListSprintsRequest { + #[schemars(description = "ID of the board")] + pub board_id: String, +} -// Read operations (list_*, get_*) use execute() without retry because they are -// idempotent and don't modify state. Write operations use execute_with_retry() -// to handle transient file conflicts from concurrent access. -#[async_trait] -impl McpTools for KanbanMcpServer { - // Board Operations +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct GetSprintRequest { + #[schemars(description = "ID of the sprint")] + pub sprint_id: String, +} - async fn create_board( - &self, - name: String, - card_prefix: Option, - ) -> Result { - let mut builder = ArgsBuilder::new(&["board", "create", "--name", &name]); - builder.add_opt("--card-prefix", card_prefix.as_deref()); - let result: serde_json::Value = self - .executor - .execute_with_retry(&builder.build(), Self::DEFAULT_RETRY_COUNT) - .await?; - Ok(json_result(result)) - } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct UpdateSprintRequest { + #[schemars(description = "ID of the sprint to update")] + pub sprint_id: String, + #[schemars(description = "New sprint name (optional)")] + pub name: Option, + #[schemars(description = "New prefix (optional)")] + pub prefix: Option, + #[schemars(description = "New card prefix (optional)")] + pub card_prefix: Option, + #[schemars(description = "New start date in YYYY-MM-DD or RFC 3339 format (optional)")] + pub start_date: Option, + #[schemars(description = "New end date in YYYY-MM-DD or RFC 3339 format (optional)")] + pub end_date: Option, + #[schemars(description = "Clear the start date")] + pub clear_start_date: Option, + #[schemars(description = "Clear the end date")] + pub clear_end_date: Option, +} - async fn list_boards(&self) -> Result { - let result: serde_json::Value = self.executor.execute(&["board", "list"]).await?; - Ok(json_result(result)) - } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct ActivateSprintRequest { + #[schemars(description = "ID of the sprint to activate")] + pub sprint_id: String, + #[schemars(description = "Duration in days (optional)")] + pub duration_days: Option, +} - async fn get_board(&self, board_id: String) -> Result { - let result: serde_json::Value = self.executor.execute(&["board", "get", &board_id]).await?; - Ok(json_result(result)) - } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct CompleteSprintRequest { + #[schemars(description = "ID of the sprint to complete")] + pub sprint_id: String, +} - async fn delete_board(&self, board_id: String) -> Result { - let result: serde_json::Value = self - .executor - .execute_with_retry(&["board", "delete", &board_id], Self::DEFAULT_RETRY_COUNT) - .await?; - Ok(json_result(result)) - } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct CancelSprintRequest { + #[schemars(description = "ID of the sprint to cancel")] + pub sprint_id: String, +} - // Column Operations +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct DeleteSprintRequest { + #[schemars(description = "ID of the sprint to delete")] + pub sprint_id: String, +} - async fn create_column( - &self, - board_id: String, - name: String, - position: Option, - ) -> Result { - let mut builder = - ArgsBuilder::new(&["column", "create", "--board-id", &board_id, "--name", &name]); - builder.add_opt_num("--position", position); - let result: serde_json::Value = self - .executor - .execute_with_retry(&builder.build(), Self::DEFAULT_RETRY_COUNT) - .await?; - Ok(json_result(result)) - } +// Export/Import - async fn list_columns(&self, board_id: String) -> Result { - let result: serde_json::Value = self - .executor - .execute(&["column", "list", "--board-id", &board_id]) - .await?; - Ok(json_result(result)) - } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct ExportBoardRequest { + #[schemars(description = "ID of the board to export (optional, exports all if omitted)")] + pub board_id: Option, +} - async fn delete_column(&self, column_id: String) -> Result { - let result: serde_json::Value = self - .executor - .execute_with_retry(&["column", "delete", &column_id], Self::DEFAULT_RETRY_COUNT) - .await?; - Ok(json_result(result)) - } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct ImportBoardRequest { + #[schemars(description = "JSON data to import (full board export format)")] + pub data: String, +} - // Card Operations +// ============================================================================ +// MCP Server +// ============================================================================ + +#[derive(Clone)] +pub struct KanbanMcpServer { + ctx: Arc>, + tool_router: ToolRouter, +} - async fn create_card(&self, params: CreateCardParams) -> Result { - let mut builder = ArgsBuilder::new(&[ - "card", - "create", - "--board-id", - ¶ms.board_id, - "--column-id", - ¶ms.column_id, - "--title", - ¶ms.title, - ]); - builder - .add_opt("--description", params.description.as_deref()) - .add_opt("--priority", params.priority.as_deref()) - .add_opt_num("--points", params.points) - .add_opt("--due-date", params.due_date.as_deref()); - let result: serde_json::Value = self - .executor - .execute_with_retry(&builder.build(), Self::DEFAULT_RETRY_COUNT) - .await?; - Ok(json_result(result)) - } - - async fn list_cards( - &self, - board_id: Option, - column_id: Option, - sprint_id: Option, - ) -> Result { - let mut builder = ArgsBuilder::new(&["card", "list"]); - builder - .add_opt("--board-id", board_id.as_deref()) - .add_opt("--column-id", column_id.as_deref()) - .add_opt("--sprint-id", sprint_id.as_deref()); - let result: serde_json::Value = self.executor.execute(&builder.build()).await?; - Ok(json_result(result)) - } - - async fn get_card(&self, card_id: String) -> Result { - let result: serde_json::Value = self.executor.execute(&["card", "get", &card_id]).await?; - Ok(json_result(result)) - } - - async fn move_card( - &self, - card_id: String, - column_id: String, - position: Option, - ) -> Result { - let mut builder = ArgsBuilder::new(&["card", "move", &card_id, "--column-id", &column_id]); - builder.add_opt_num("--position", position); - let result: serde_json::Value = self - .executor - .execute_with_retry(&builder.build(), Self::DEFAULT_RETRY_COUNT) - .await?; - Ok(json_result(result)) - } - - async fn update_card(&self, params: UpdateCardParams) -> Result { - let mut builder = ArgsBuilder::new(&["card", "update", ¶ms.card_id]); - builder - .add_opt("--title", params.title.as_deref()) - .add_opt("--description", params.description.as_deref()) - .add_flag("--clear-description", params.clear_description) - .add_opt("--priority", params.priority.as_deref()) - .add_opt("--status", params.status.as_deref()) - .add_opt("--due-date", params.due_date.as_deref()) - .add_opt_num("--points", params.points) - .add_flag("--clear-due-date", params.clear_due_date) - .add_flag("--clear-points", params.clear_points); - let result: serde_json::Value = self - .executor - .execute_with_retry(&builder.build(), Self::DEFAULT_RETRY_COUNT) - .await?; - Ok(json_result(result)) - } - - async fn archive_card(&self, card_id: String) -> Result { - let result: serde_json::Value = self - .executor - .execute_with_retry(&["card", "archive", &card_id], Self::DEFAULT_RETRY_COUNT) - .await?; - Ok(json_result(result)) - } - - async fn delete_card(&self, card_id: String) -> Result { - let result: serde_json::Value = self - .executor - .execute_with_retry(&["card", "delete", &card_id], Self::DEFAULT_RETRY_COUNT) - .await?; - Ok(json_result(result)) +impl KanbanMcpServer { + pub fn new(data_file: &str) -> Self { + Self { + ctx: Arc::new(Mutex::new(McpContext::new(data_file))), + tool_router: Self::tool_router(), + } } } // ============================================================================ -// MCP Tool Wrappers (thin layer exposing trait methods as MCP tools) +// MCP Tool Wrappers // ============================================================================ #[tool_router] @@ -397,12 +487,14 @@ impl KanbanMcpServer { &self, Parameters(req): Parameters, ) -> Result { - McpTools::create_board(self, req.name, req.card_prefix).await + let board = spawn_op!(self.ctx, create_board, req.name, req.card_prefix)?; + to_call_tool_result(&board) } #[tool(description = "List all kanban boards")] async fn tool_list_boards(&self) -> Result { - McpTools::list_boards(self).await + let boards = spawn_op_ref!(self.ctx, list_boards)?; + to_call_tool_result(&boards) } #[tool(description = "Get a specific board by ID")] @@ -410,7 +502,37 @@ impl KanbanMcpServer { &self, Parameters(req): Parameters, ) -> Result { - McpTools::get_board(self, req.board_id).await + let id = parse_uuid(&req.board_id)?; + let board = spawn_op_ref!(self.ctx, get_board, id)?; + to_call_tool_result(&board) + } + + #[tool( + description = "Update a board's properties (name, description, sprint_prefix, card_prefix)" + )] + async fn tool_update_board( + &self, + Parameters(req): Parameters, + ) -> Result { + let id = parse_uuid(&req.board_id)?; + let updates = BoardUpdate { + name: req.name, + description: req + .description + .map(FieldUpdate::Set) + .unwrap_or(FieldUpdate::NoChange), + sprint_prefix: req + .sprint_prefix + .map(FieldUpdate::Set) + .unwrap_or(FieldUpdate::NoChange), + card_prefix: req + .card_prefix + .map(FieldUpdate::Set) + .unwrap_or(FieldUpdate::NoChange), + ..Default::default() + }; + let board = spawn_op!(self.ctx, update_board, id, updates)?; + to_call_tool_result(&board) } #[tool(description = "Delete a board and all its columns, cards, and sprints")] @@ -418,7 +540,9 @@ impl KanbanMcpServer { &self, Parameters(req): Parameters, ) -> Result { - McpTools::delete_board(self, req.board_id).await + let id = parse_uuid(&req.board_id)?; + spawn_op!(self.ctx, delete_board, id)?; + to_call_tool_result_json(serde_json::json!({"deleted": req.board_id})) } // Column Operations @@ -428,7 +552,9 @@ impl KanbanMcpServer { &self, Parameters(req): Parameters, ) -> Result { - McpTools::create_column(self, req.board_id, req.name, req.position).await + let board_id = parse_uuid(&req.board_id)?; + let column = spawn_op!(self.ctx, create_column, board_id, req.name, req.position)?; + to_call_tool_result(&column) } #[tool(description = "List all columns in a board")] @@ -436,7 +562,40 @@ impl KanbanMcpServer { &self, Parameters(req): Parameters, ) -> Result { - McpTools::list_columns(self, req.board_id).await + let board_id = parse_uuid(&req.board_id)?; + let columns = spawn_op_ref!(self.ctx, list_columns, board_id)?; + to_call_tool_result(&columns) + } + + #[tool(description = "Get a specific column by ID")] + async fn tool_get_column( + &self, + Parameters(req): Parameters, + ) -> Result { + let id = parse_uuid(&req.column_id)?; + let column = spawn_op_ref!(self.ctx, get_column, id)?; + to_call_tool_result(&column) + } + + #[tool(description = "Update a column's properties (name, position, wip_limit)")] + async fn tool_update_column( + &self, + Parameters(req): Parameters, + ) -> Result { + let id = parse_uuid(&req.column_id)?; + let updates = ColumnUpdate { + name: req.name, + position: req.position, + wip_limit: if req.clear_wip_limit == Some(true) { + FieldUpdate::Clear + } else { + req.wip_limit + .map(|w| FieldUpdate::Set(w as i32)) + .unwrap_or(FieldUpdate::NoChange) + }, + }; + let column = spawn_op!(self.ctx, update_column, id, updates)?; + to_call_tool_result(&column) } #[tool(description = "Delete a column and all its cards")] @@ -444,7 +603,19 @@ impl KanbanMcpServer { &self, Parameters(req): Parameters, ) -> Result { - McpTools::delete_column(self, req.column_id).await + let id = parse_uuid(&req.column_id)?; + spawn_op!(self.ctx, delete_column, id)?; + to_call_tool_result_json(serde_json::json!({"deleted": req.column_id})) + } + + #[tool(description = "Reorder a column to a new position")] + async fn tool_reorder_column( + &self, + Parameters(req): Parameters, + ) -> Result { + let id = parse_uuid(&req.column_id)?; + let column = spawn_op!(self.ctx, reorder_column, id, req.position)?; + to_call_tool_result(&column) } // Card Operations @@ -454,19 +625,27 @@ impl KanbanMcpServer { &self, Parameters(req): Parameters, ) -> Result { - McpTools::create_card( - self, - CreateCardParams { - board_id: req.board_id, - column_id: req.column_id, - title: req.title, - description: req.description, - priority: req.priority, - points: req.points, - due_date: req.due_date, - }, - ) - .await + let board_id = parse_uuid(&req.board_id)?; + let column_id = parse_uuid(&req.column_id)?; + let priority = req.priority.as_deref().map(parse_priority).transpose()?; + let due_date = req.due_date.as_deref().map(parse_datetime).transpose()?; + + let options = CreateCardOptions { + description: req.description, + priority, + points: req.points, + due_date, + }; + + let card = spawn_op!( + self.ctx, + create_card, + board_id, + column_id, + req.title, + options + )?; + to_call_tool_result(&card) } #[tool(description = "List cards with optional filters")] @@ -474,7 +653,19 @@ impl KanbanMcpServer { &self, Parameters(req): Parameters, ) -> Result { - McpTools::list_cards(self, req.board_id, req.column_id, req.sprint_id).await + let board_id = req.board_id.as_deref().map(parse_uuid).transpose()?; + let column_id = req.column_id.as_deref().map(parse_uuid).transpose()?; + let sprint_id = req.sprint_id.as_deref().map(parse_uuid).transpose()?; + let status = req.status.as_deref().map(parse_status).transpose()?; + + let filter = CardListFilter { + board_id, + column_id, + sprint_id, + status, + }; + let cards = spawn_op_ref!(self.ctx, list_cards, filter)?; + to_call_tool_result(&cards) } #[tool(description = "Get a specific card by ID")] @@ -482,15 +673,9 @@ impl KanbanMcpServer { &self, Parameters(req): Parameters, ) -> Result { - McpTools::get_card(self, req.card_id).await - } - - #[tool(description = "Move a card to a different column")] - async fn tool_move_card( - &self, - Parameters(req): Parameters, - ) -> Result { - McpTools::move_card(self, req.card_id, req.column_id, req.position).await + let id = parse_uuid(&req.card_id)?; + let card = spawn_op_ref!(self.ctx, get_card, id)?; + to_call_tool_result(&card) } #[tool( @@ -500,22 +685,49 @@ impl KanbanMcpServer { &self, Parameters(req): Parameters, ) -> Result { - McpTools::update_card( - self, - UpdateCardParams { - card_id: req.card_id, - title: req.title, - description: req.description, - clear_description: req.clear_description, - priority: req.priority, - status: req.status, - due_date: req.due_date, - clear_due_date: req.clear_due_date, - points: req.points, - clear_points: req.clear_points, + let id = parse_uuid(&req.card_id)?; + let priority = req.priority.as_deref().map(parse_priority).transpose()?; + let status = req.status.as_deref().map(parse_status).transpose()?; + + let updates = CardUpdate { + title: req.title, + description: req + .description + .map(FieldUpdate::Set) + .unwrap_or(FieldUpdate::NoChange), + priority, + status, + position: None, + column_id: None, + points: req + .points + .map(FieldUpdate::Set) + .unwrap_or(FieldUpdate::NoChange), + due_date: if req.clear_due_date == Some(true) { + FieldUpdate::Clear + } else { + match req.due_date { + Some(ref d) => FieldUpdate::Set(parse_datetime(d)?), + None => FieldUpdate::NoChange, + } }, - ) - .await + sprint_id: FieldUpdate::NoChange, + assigned_prefix: FieldUpdate::NoChange, + card_prefix: FieldUpdate::NoChange, + }; + let card = spawn_op!(self.ctx, update_card, id, updates)?; + to_call_tool_result(&card) + } + + #[tool(description = "Move a card to a different column")] + async fn tool_move_card( + &self, + Parameters(req): Parameters, + ) -> Result { + let id = parse_uuid(&req.card_id)?; + let column_id = parse_uuid(&req.column_id)?; + let card = spawn_op!(self.ctx, move_card, id, column_id, req.position)?; + to_call_tool_result(&card) } #[tool(description = "Archive a card (move to archive, can be restored later)")] @@ -523,7 +735,20 @@ impl KanbanMcpServer { &self, Parameters(req): Parameters, ) -> Result { - McpTools::archive_card(self, req.card_id).await + let id = parse_uuid(&req.card_id)?; + spawn_op!(self.ctx, archive_card, id)?; + to_call_tool_result_json(serde_json::json!({"archived": req.card_id})) + } + + #[tool(description = "Restore an archived card")] + async fn tool_restore_card( + &self, + Parameters(req): Parameters, + ) -> Result { + let id = parse_uuid(&req.card_id)?; + let column_id = req.column_id.as_deref().map(parse_uuid).transpose()?; + let card = spawn_op!(self.ctx, restore_card, id, column_id)?; + to_call_tool_result(&card) } #[tool(description = "Delete a card permanently")] @@ -531,7 +756,235 @@ impl KanbanMcpServer { &self, Parameters(req): Parameters, ) -> Result { - McpTools::delete_card(self, req.card_id).await + let id = parse_uuid(&req.card_id)?; + spawn_op!(self.ctx, delete_card, id)?; + to_call_tool_result_json(serde_json::json!({"deleted": req.card_id})) + } + + #[tool(description = "List archived cards")] + async fn tool_list_archived_cards(&self) -> Result { + let cards = spawn_op_ref!(self.ctx, list_archived_cards)?; + to_call_tool_result(&cards) + } + + // Card Sprint Operations + + #[tool(description = "Assign a card to a sprint")] + async fn tool_assign_card_to_sprint( + &self, + Parameters(req): Parameters, + ) -> Result { + let card_id = parse_uuid(&req.card_id)?; + let sprint_id = parse_uuid(&req.sprint_id)?; + let card = spawn_op!(self.ctx, assign_card_to_sprint, card_id, sprint_id)?; + to_call_tool_result(&card) + } + + #[tool(description = "Unassign a card from its sprint")] + async fn tool_unassign_card_from_sprint( + &self, + Parameters(req): Parameters, + ) -> Result { + let card_id = parse_uuid(&req.card_id)?; + let card = spawn_op!(self.ctx, unassign_card_from_sprint, card_id)?; + to_call_tool_result(&card) + } + + // Card Utilities + + #[tool(description = "Get the git branch name for a card")] + async fn tool_get_card_branch_name( + &self, + Parameters(req): Parameters, + ) -> Result { + let id = parse_uuid(&req.card_id)?; + let branch_name = spawn_op_ref!(self.ctx, get_card_branch_name, id)?; + to_call_tool_result_json(serde_json::json!({"branch_name": branch_name})) + } + + #[tool(description = "Get the git checkout command for a card")] + async fn tool_get_card_git_checkout( + &self, + Parameters(req): Parameters, + ) -> Result { + let id = parse_uuid(&req.card_id)?; + let command = spawn_op_ref!(self.ctx, get_card_git_checkout, id)?; + to_call_tool_result_json(serde_json::json!({"command": command})) + } + + // Bulk Operations + + #[tool(description = "Archive multiple cards at once")] + async fn tool_bulk_archive_cards( + &self, + Parameters(req): Parameters, + ) -> Result { + let ids = parse_uuids_csv(&req.ids)?; + let count = spawn_op!(self.ctx, bulk_archive_cards, ids)?; + to_call_tool_result_json(serde_json::json!({"archived_count": count})) + } + + #[tool(description = "Move multiple cards to a column")] + async fn tool_bulk_move_cards( + &self, + Parameters(req): Parameters, + ) -> Result { + let ids = parse_uuids_csv(&req.ids)?; + let column_id = parse_uuid(&req.column_id)?; + let count = spawn_op!(self.ctx, bulk_move_cards, ids, column_id)?; + to_call_tool_result_json(serde_json::json!({"moved_count": count})) + } + + #[tool(description = "Assign multiple cards to a sprint")] + async fn tool_bulk_assign_sprint( + &self, + Parameters(req): Parameters, + ) -> Result { + let ids = parse_uuids_csv(&req.ids)?; + let sprint_id = parse_uuid(&req.sprint_id)?; + let count = spawn_op!(self.ctx, bulk_assign_sprint, ids, sprint_id)?; + to_call_tool_result_json(serde_json::json!({"assigned_count": count})) + } + + // Sprint Operations + + #[tool(description = "Create a new sprint")] + async fn tool_create_sprint( + &self, + Parameters(req): Parameters, + ) -> Result { + let board_id = parse_uuid(&req.board_id)?; + let sprint = spawn_op!(self.ctx, create_sprint, board_id, req.prefix, req.name)?; + to_call_tool_result(&sprint) + } + + #[tool(description = "List sprints for a board")] + async fn tool_list_sprints( + &self, + Parameters(req): Parameters, + ) -> Result { + let board_id = parse_uuid(&req.board_id)?; + let sprints = spawn_op_ref!(self.ctx, list_sprints, board_id)?; + to_call_tool_result(&sprints) + } + + #[tool(description = "Get a specific sprint by ID")] + async fn tool_get_sprint( + &self, + Parameters(req): Parameters, + ) -> Result { + let id = parse_uuid(&req.sprint_id)?; + let sprint = spawn_op_ref!(self.ctx, get_sprint, id)?; + to_call_tool_result(&sprint) + } + + #[tool( + description = "Update a sprint's properties (name, prefix, card_prefix, start_date, end_date)" + )] + async fn tool_update_sprint( + &self, + Parameters(req): Parameters, + ) -> Result { + let id = parse_uuid(&req.sprint_id)?; + + let start_date = if req.clear_start_date == Some(true) { + FieldUpdate::Clear + } else { + match req.start_date { + Some(ref d) => FieldUpdate::Set(parse_datetime(d)?), + None => FieldUpdate::NoChange, + } + }; + + let end_date = if req.clear_end_date == Some(true) { + FieldUpdate::Clear + } else { + match req.end_date { + Some(ref d) => FieldUpdate::Set(parse_datetime(d)?), + None => FieldUpdate::NoChange, + } + }; + + let updates = SprintUpdate { + name: req.name, + name_index: FieldUpdate::NoChange, + prefix: req + .prefix + .map(FieldUpdate::Set) + .unwrap_or(FieldUpdate::NoChange), + card_prefix: req + .card_prefix + .map(FieldUpdate::Set) + .unwrap_or(FieldUpdate::NoChange), + status: None, + start_date, + end_date, + }; + + let sprint = spawn_op!(self.ctx, update_sprint, id, updates)?; + to_call_tool_result(&sprint) + } + + #[tool(description = "Activate a sprint")] + async fn tool_activate_sprint( + &self, + Parameters(req): Parameters, + ) -> Result { + let id = parse_uuid(&req.sprint_id)?; + let sprint = spawn_op!(self.ctx, activate_sprint, id, req.duration_days)?; + to_call_tool_result(&sprint) + } + + #[tool(description = "Complete a sprint")] + async fn tool_complete_sprint( + &self, + Parameters(req): Parameters, + ) -> Result { + let id = parse_uuid(&req.sprint_id)?; + let sprint = spawn_op!(self.ctx, complete_sprint, id)?; + to_call_tool_result(&sprint) + } + + #[tool(description = "Cancel a sprint")] + async fn tool_cancel_sprint( + &self, + Parameters(req): Parameters, + ) -> Result { + let id = parse_uuid(&req.sprint_id)?; + let sprint = spawn_op!(self.ctx, cancel_sprint, id)?; + to_call_tool_result(&sprint) + } + + #[tool(description = "Delete a sprint")] + async fn tool_delete_sprint( + &self, + Parameters(req): Parameters, + ) -> Result { + let id = parse_uuid(&req.sprint_id)?; + spawn_op!(self.ctx, delete_sprint, id)?; + to_call_tool_result_json(serde_json::json!({"deleted": req.sprint_id})) + } + + // Export/Import + + #[tool(description = "Export board data as JSON")] + async fn tool_export_board( + &self, + Parameters(req): Parameters, + ) -> Result { + let board_id = req.board_id.as_deref().map(parse_uuid).transpose()?; + let json = spawn_op_ref!(self.ctx, export_board, board_id)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + #[tool(description = "Import board data from JSON")] + async fn tool_import_board( + &self, + Parameters(req): Parameters, + ) -> Result { + let data = req.data; + let board = spawn_op!(self.ctx, import_board, &data)?; + to_call_tool_result(&board) } } @@ -554,3 +1007,239 @@ impl ServerHandler for KanbanMcpServer { } } } + +#[cfg(test)] +mod tests { + use super::*; + + // parse_uuid + + #[test] + fn parse_uuid_valid() { + let id = "550e8400-e29b-41d4-a716-446655440000"; + let result = parse_uuid(id).unwrap(); + assert_eq!(result.to_string(), id); + } + + #[test] + fn parse_uuid_invalid() { + let err = parse_uuid("not-a-uuid").unwrap_err(); + assert!(err.message.contains("Invalid UUID")); + } + + #[test] + fn parse_uuid_empty() { + let err = parse_uuid("").unwrap_err(); + assert!(err.message.contains("Invalid UUID")); + } + + // parse_priority + + #[test] + fn parse_priority_all_valid() { + assert!(matches!(parse_priority("low").unwrap(), CardPriority::Low)); + assert!(matches!( + parse_priority("medium").unwrap(), + CardPriority::Medium + )); + assert!(matches!( + parse_priority("high").unwrap(), + CardPriority::High + )); + assert!(matches!( + parse_priority("critical").unwrap(), + CardPriority::Critical + )); + } + + #[test] + fn parse_priority_case_insensitive() { + assert!(matches!(parse_priority("LOW").unwrap(), CardPriority::Low)); + assert!(matches!( + parse_priority("High").unwrap(), + CardPriority::High + )); + assert!(matches!( + parse_priority("CRITICAL").unwrap(), + CardPriority::Critical + )); + } + + #[test] + fn parse_priority_invalid() { + let err = parse_priority("urgent").unwrap_err(); + assert!(err.message.contains("Invalid priority")); + } + + // parse_status + + #[test] + fn parse_status_all_valid() { + assert!(matches!(parse_status("todo").unwrap(), CardStatus::Todo)); + assert!(matches!( + parse_status("in_progress").unwrap(), + CardStatus::InProgress + )); + assert!(matches!( + parse_status("blocked").unwrap(), + CardStatus::Blocked + )); + assert!(matches!(parse_status("done").unwrap(), CardStatus::Done)); + } + + #[test] + fn parse_status_hyphen_underscore_normalization() { + assert!(matches!( + parse_status("in-progress").unwrap(), + CardStatus::InProgress + )); + assert!(matches!( + parse_status("in_progress").unwrap(), + CardStatus::InProgress + )); + assert!(matches!( + parse_status("InProgress").unwrap(), + CardStatus::InProgress + )); + } + + #[test] + fn parse_status_invalid() { + let err = parse_status("cancelled").unwrap_err(); + assert!(err.message.contains("Invalid status")); + } + + // parse_datetime + + #[test] + fn parse_datetime_rfc3339() { + let dt = parse_datetime("2024-06-15T10:30:00Z").unwrap(); + assert_eq!(dt.to_rfc3339(), "2024-06-15T10:30:00+00:00"); + } + + #[test] + fn parse_datetime_date_only() { + let dt = parse_datetime("2024-06-15").unwrap(); + assert_eq!(dt.to_rfc3339(), "2024-06-15T00:00:00+00:00"); + } + + #[test] + fn parse_datetime_invalid() { + let err = parse_datetime("not-a-date").unwrap_err(); + assert!(err.message.contains("Invalid date")); + } + + // parse_uuids_csv + + #[test] + fn parse_uuids_csv_single() { + let id = "550e8400-e29b-41d4-a716-446655440000"; + let result = parse_uuids_csv(id).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].to_string(), id); + } + + #[test] + fn parse_uuids_csv_multiple() { + let ids = "550e8400-e29b-41d4-a716-446655440000,660e8400-e29b-41d4-a716-446655440001"; + let result = parse_uuids_csv(ids).unwrap(); + assert_eq!(result.len(), 2); + } + + #[test] + fn parse_uuids_csv_with_spaces() { + let ids = "550e8400-e29b-41d4-a716-446655440000 , 660e8400-e29b-41d4-a716-446655440001"; + let result = parse_uuids_csv(ids).unwrap(); + assert_eq!(result.len(), 2); + } + + #[test] + fn parse_uuids_csv_invalid_in_list() { + let ids = "550e8400-e29b-41d4-a716-446655440000,bad-uuid"; + let err = parse_uuids_csv(ids).unwrap_err(); + assert!(err.message.contains("Invalid UUID")); + } + + // to_call_tool_result / to_call_tool_result_json + + #[test] + fn to_call_tool_result_serializes_struct() { + use rmcp::model::RawContent; + #[derive(serde::Serialize)] + struct Foo { + x: i32, + } + let result = to_call_tool_result(&Foo { x: 42 }).unwrap(); + match &result.content[0].raw { + RawContent::Text(t) => assert!(t.text.contains("42")), + _ => panic!("Expected text content"), + } + } + + #[test] + fn to_call_tool_result_json_serializes_value() { + use rmcp::model::RawContent; + let val = serde_json::json!({"key": "value"}); + let result = to_call_tool_result_json(val).unwrap(); + match &result.content[0].raw { + RawContent::Text(t) => { + assert!(t.text.contains("key")); + assert!(t.text.contains("value")); + } + _ => panic!("Expected text content"), + } + } + + // kanban_err_to_mcp + + #[test] + fn err_not_found_maps_to_invalid_params() { + use rmcp::model::ErrorCode; + let err = kanban_err_to_mcp(KanbanError::NotFound("board xyz".into())); + assert_eq!(err.code, ErrorCode::INVALID_PARAMS); + assert!(err.message.contains("board xyz")); + } + + #[test] + fn err_validation_maps_to_invalid_params() { + use rmcp::model::ErrorCode; + let err = kanban_err_to_mcp(KanbanError::Validation("bad input".into())); + assert_eq!(err.code, ErrorCode::INVALID_PARAMS); + } + + #[test] + fn err_cycle_maps_to_invalid_params() { + use rmcp::model::ErrorCode; + let err = kanban_err_to_mcp(KanbanError::CycleDetected); + assert_eq!(err.code, ErrorCode::INVALID_PARAMS); + } + + #[test] + fn err_self_ref_maps_to_invalid_params() { + use rmcp::model::ErrorCode; + let err = kanban_err_to_mcp(KanbanError::SelfReference); + assert_eq!(err.code, ErrorCode::INVALID_PARAMS); + } + + #[test] + fn err_edge_not_found_maps_to_invalid_params() { + use rmcp::model::ErrorCode; + let err = kanban_err_to_mcp(KanbanError::EdgeNotFound); + assert_eq!(err.code, ErrorCode::INVALID_PARAMS); + } + + #[test] + fn err_internal_maps_to_internal_error() { + use rmcp::model::ErrorCode; + let err = kanban_err_to_mcp(KanbanError::Internal("boom".into())); + assert_eq!(err.code, ErrorCode::INTERNAL_ERROR); + } + + #[test] + fn err_io_maps_to_internal_error() { + use rmcp::model::ErrorCode; + let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file gone"); + let err = kanban_err_to_mcp(KanbanError::Io(io_err)); + assert_eq!(err.code, ErrorCode::INTERNAL_ERROR); + } +} diff --git a/crates/kanban-mcp/src/tools_trait.rs b/crates/kanban-mcp/src/tools_trait.rs deleted file mode 100644 index 54dd763d..00000000 --- a/crates/kanban-mcp/src/tools_trait.rs +++ /dev/null @@ -1,94 +0,0 @@ -use async_trait::async_trait; -use rmcp::model::{CallToolResult, ErrorData as McpError}; - -/// Parameters for creating a card -#[derive(Debug, Default)] -pub struct CreateCardParams { - pub board_id: String, - pub column_id: String, - pub title: String, - pub description: Option, - pub priority: Option, - pub points: Option, - pub due_date: Option, -} - -/// Parameters for updating a card -#[derive(Debug, Default)] -pub struct UpdateCardParams { - pub card_id: String, - pub title: Option, - pub description: Option, - pub clear_description: Option, - pub priority: Option, - pub status: Option, - pub due_date: Option, - pub clear_due_date: Option, - pub points: Option, - pub clear_points: Option, -} - -/// Async MCP-compatible operations trait. -/// Mirrors KanbanOperations from kanban-domain but with MCP return types. -/// When adding operations to KanbanOperations, add them here too. -#[async_trait] -pub trait McpTools { - // ======================================================================== - // Board Operations - // ======================================================================== - - async fn create_board( - &self, - name: String, - card_prefix: Option, - ) -> Result; - - async fn list_boards(&self) -> Result; - - async fn get_board(&self, board_id: String) -> Result; - - async fn delete_board(&self, board_id: String) -> Result; - - // ======================================================================== - // Column Operations - // ======================================================================== - - async fn create_column( - &self, - board_id: String, - name: String, - position: Option, - ) -> Result; - - async fn list_columns(&self, board_id: String) -> Result; - - async fn delete_column(&self, column_id: String) -> Result; - - // ======================================================================== - // Card Operations - // ======================================================================== - - async fn create_card(&self, params: CreateCardParams) -> Result; - - async fn list_cards( - &self, - board_id: Option, - column_id: Option, - sprint_id: Option, - ) -> Result; - - async fn get_card(&self, card_id: String) -> Result; - - async fn move_card( - &self, - card_id: String, - column_id: String, - position: Option, - ) -> Result; - - async fn update_card(&self, params: UpdateCardParams) -> Result; - - async fn archive_card(&self, card_id: String) -> Result; - - async fn delete_card(&self, card_id: String) -> Result; -} diff --git a/crates/kanban-mcp/tests/integration.rs b/crates/kanban-mcp/tests/integration.rs new file mode 100644 index 00000000..5c34616f --- /dev/null +++ b/crates/kanban-mcp/tests/integration.rs @@ -0,0 +1,288 @@ +use kanban_domain::KanbanOperations; +use kanban_mcp::context::McpContext; +use tempfile::TempDir; + +fn kanban_bin() -> String { + if let Ok(path) = std::env::var("KANBAN_BIN") { + return path; + } + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let workspace_root = std::path::Path::new(manifest_dir) + .parent() + .unwrap() + .parent() + .unwrap(); + let bin = workspace_root.join("target/debug/kanban"); + assert!( + bin.exists(), + "kanban binary not found at {:?}. Run `cargo build --bin kanban` first.", + bin + ); + bin.to_string_lossy().to_string() +} + +fn setup() -> (McpContext, TempDir) { + let dir = TempDir::new().expect("failed to create temp dir"); + let path = dir.path().join("test.kanban"); + let path_str = path.to_string_lossy().to_string(); + let ctx = McpContext::new(&path_str).with_kanban_path(&kanban_bin()); + (ctx, dir) +} + +// Board round-trips + +#[test] +fn board_create_list_get() { + let (mut ctx, _tmp) = setup(); + let board = ctx + .create_board("Test Board".into(), Some("TB".into())) + .unwrap(); + assert_eq!(board.name, "Test Board"); + + let boards = ctx.list_boards().unwrap(); + assert_eq!(boards.len(), 1); + assert_eq!(boards[0].id, board.id); + + let fetched = ctx.get_board(board.id).unwrap().unwrap(); + assert_eq!(fetched.name, "Test Board"); +} + +#[test] +fn board_get_nonexistent() { + let (ctx, _tmp) = setup(); + let id = uuid::Uuid::new_v4(); + let result = ctx.get_board(id).unwrap(); + assert!(result.is_none()); +} + +// Column round-trips + +#[test] +fn column_create_list_update() { + let (mut ctx, _tmp) = setup(); + let board = ctx.create_board("Board".into(), None).unwrap(); + let col = ctx.create_column(board.id, "To Do".into(), None).unwrap(); + assert_eq!(col.name, "To Do"); + + let cols = ctx.list_columns(board.id).unwrap(); + assert!(cols.iter().any(|c| c.id == col.id)); + + let updated = ctx + .update_column( + col.id, + kanban_domain::ColumnUpdate { + name: Some("Done".into()), + position: None, + wip_limit: kanban_domain::FieldUpdate::NoChange, + }, + ) + .unwrap(); + assert_eq!(updated.name, "Done"); +} + +#[test] +fn column_reorder() { + let (mut ctx, _tmp) = setup(); + let board = ctx.create_board("Board".into(), None).unwrap(); + let _c1 = ctx + .create_column(board.id, "Col A".into(), Some(0)) + .unwrap(); + let c2 = ctx + .create_column(board.id, "Col B".into(), Some(1)) + .unwrap(); + let reordered = ctx.reorder_column(c2.id, 0).unwrap(); + assert_eq!(reordered.position, 0); +} + +// Card round-trips + +#[test] +fn card_create_get_move_archive_restore() { + let (mut ctx, _tmp) = setup(); + let board = ctx.create_board("Board".into(), None).unwrap(); + let col1 = ctx.create_column(board.id, "To Do".into(), None).unwrap(); + let col2 = ctx.create_column(board.id, "Done".into(), None).unwrap(); + + let card = ctx + .create_card(board.id, col1.id, "My Card".into(), Default::default()) + .unwrap(); + assert_eq!(card.title, "My Card"); + + let fetched = ctx.get_card(card.id).unwrap().unwrap(); + assert_eq!(fetched.id, card.id); + + let moved = ctx.move_card(card.id, col2.id, None).unwrap(); + assert_eq!(moved.column_id, col2.id); + + ctx.archive_card(card.id).unwrap(); + let archived = ctx.list_archived_cards().unwrap(); + assert!(archived.iter().any(|c| c.card.id == card.id)); + + let restored = ctx.restore_card(card.id, None).unwrap(); + assert_eq!(restored.id, card.id); +} + +#[test] +fn create_card_then_update_with_all_fields() { + let (mut ctx, _tmp) = setup(); + let board = ctx.create_board("Board".into(), None).unwrap(); + let col = ctx.create_column(board.id, "To Do".into(), None).unwrap(); + + let card = ctx + .create_card(board.id, col.id, "Full Card".into(), Default::default()) + .unwrap(); + assert_eq!(card.title, "Full Card"); + + let updated = ctx + .update_card( + card.id, + kanban_domain::CardUpdate { + title: None, + description: kanban_domain::FieldUpdate::Set("A description".into()), + priority: Some(kanban_domain::CardPriority::High), + status: None, + position: None, + column_id: None, + points: kanban_domain::FieldUpdate::Set(5), + due_date: kanban_domain::FieldUpdate::NoChange, + sprint_id: kanban_domain::FieldUpdate::NoChange, + assigned_prefix: kanban_domain::FieldUpdate::NoChange, + card_prefix: kanban_domain::FieldUpdate::NoChange, + }, + ) + .unwrap(); + assert_eq!(updated.title, "Full Card"); + assert_eq!(updated.description.as_deref(), Some("A description")); +} + +// Sprint round-trips + +#[test] +fn sprint_create_list_activate_complete() { + let (mut ctx, _tmp) = setup(); + let board = ctx.create_board("Board".into(), None).unwrap(); + + let sprint = ctx.create_sprint(board.id, None, None).unwrap(); + let sprints = ctx.list_sprints(board.id).unwrap(); + assert_eq!(sprints.len(), 1); + assert_eq!(sprints[0].id, sprint.id); + + let activated = ctx.activate_sprint(sprint.id, Some(14)).unwrap(); + assert_eq!(activated.id, sprint.id); + + let completed = ctx.complete_sprint(sprint.id).unwrap(); + assert_eq!(completed.id, sprint.id); +} + +#[test] +fn sprint_update_via_trait() { + let (mut ctx, _tmp) = setup(); + let board = ctx.create_board("Board".into(), None).unwrap(); + let sprint = ctx.create_sprint(board.id, None, None).unwrap(); + + let updated = ctx + .update_sprint( + sprint.id, + kanban_domain::SprintUpdate { + name: Some("Sprint Alpha".into()), + name_index: kanban_domain::FieldUpdate::NoChange, + prefix: kanban_domain::FieldUpdate::Set("SA".into()), + card_prefix: kanban_domain::FieldUpdate::NoChange, + status: None, + start_date: kanban_domain::FieldUpdate::Set( + chrono::NaiveDate::from_ymd_opt(2025, 1, 1) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc(), + ), + end_date: kanban_domain::FieldUpdate::Set( + chrono::NaiveDate::from_ymd_opt(2025, 1, 15) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc(), + ), + }, + ) + .unwrap(); + assert_eq!(updated.id, sprint.id); +} + +#[test] +fn sprint_cancel() { + let (mut ctx, _tmp) = setup(); + let board = ctx.create_board("Board".into(), None).unwrap(); + let sprint = ctx.create_sprint(board.id, None, None).unwrap(); + let _ = ctx.activate_sprint(sprint.id, None).unwrap(); + let cancelled = ctx.cancel_sprint(sprint.id).unwrap(); + assert_eq!(cancelled.id, sprint.id); +} + +// Card-sprint assignment + +#[test] +fn card_assign_unassign_sprint() { + let (mut ctx, _tmp) = setup(); + let board = ctx.create_board("Board".into(), None).unwrap(); + let col = ctx.create_column(board.id, "To Do".into(), None).unwrap(); + let card = ctx + .create_card(board.id, col.id, "Card".into(), Default::default()) + .unwrap(); + let sprint = ctx.create_sprint(board.id, None, None).unwrap(); + + let assigned = ctx.assign_card_to_sprint(card.id, sprint.id).unwrap(); + assert_eq!(assigned.sprint_id, Some(sprint.id)); + + let unassigned = ctx.unassign_card_from_sprint(card.id).unwrap(); + assert_eq!(unassigned.sprint_id, None); +} + +// Bulk operations + +#[test] +fn bulk_archive() { + let (mut ctx, _tmp) = setup(); + let board = ctx.create_board("Board".into(), None).unwrap(); + let col = ctx.create_column(board.id, "Col".into(), None).unwrap(); + let c1 = ctx + .create_card(board.id, col.id, "Card 1".into(), Default::default()) + .unwrap(); + let c2 = ctx + .create_card(board.id, col.id, "Card 2".into(), Default::default()) + .unwrap(); + + let count = ctx.bulk_archive_cards(vec![c1.id, c2.id]).unwrap(); + assert_eq!(count, 2); +} + +#[test] +fn bulk_move() { + let (mut ctx, _tmp) = setup(); + let board = ctx.create_board("Board".into(), None).unwrap(); + let col1 = ctx.create_column(board.id, "From".into(), None).unwrap(); + let col2 = ctx.create_column(board.id, "To".into(), None).unwrap(); + let c1 = ctx + .create_card(board.id, col1.id, "Card 1".into(), Default::default()) + .unwrap(); + let c2 = ctx + .create_card(board.id, col1.id, "Card 2".into(), Default::default()) + .unwrap(); + + let count = ctx.bulk_move_cards(vec![c1.id, c2.id], col2.id).unwrap(); + assert_eq!(count, 2); +} + +// Export/Import round-trip + +#[test] +fn export_import_roundtrip() { + let (mut ctx, _tmp) = setup(); + let board = ctx.create_board("Export Board".into(), None).unwrap(); + let _col = ctx.create_column(board.id, "Col".into(), None).unwrap(); + + let json = ctx.export_board(Some(board.id)).unwrap(); + assert!(json.contains("Export Board")); + + ctx.import_board(&json).unwrap(); +} diff --git a/crates/kanban-tui/src/handlers/card_handlers.rs b/crates/kanban-tui/src/handlers/card_handlers.rs index 257ca0ee..c281b428 100644 --- a/crates/kanban-tui/src/handlers/card_handlers.rs +++ b/crates/kanban-tui/src/handlers/card_handlers.rs @@ -1,9 +1,7 @@ use crate::app::{App, AppMode, CardField, DialogMode, Focus}; use crate::card_list::CardListId; use crate::events::EventHandler; -use kanban_domain::commands::{ - ArchiveCard, CreateCard, DeleteCard, MoveCard, RestoreCard, SetBoardTaskSort, UpdateCard, -}; +use kanban_domain::commands::{CreateCard, MoveCard, RestoreCard, SetBoardTaskSort, UpdateCard}; use kanban_domain::{ArchivedCard, CardStatus, CardUpdate, Column, SortOrder, Sprint}; use ratatui::{backend::CrosstermBackend, Terminal}; use std::io; @@ -339,6 +337,7 @@ impl App { column_id: column.id, title: self.input.as_str().to_string(), position, + options: kanban_domain::CreateCardOptions::default(), }); if let Err(e) = self.execute_command(create_cmd) { @@ -516,36 +515,6 @@ impl App { } } - #[allow(dead_code)] - fn delete_card(&mut self, card_id: uuid::Uuid) -> bool { - // Store info before executing command - let deleted_info = self - .ctx - .cards - .iter() - .find(|c| c.id == card_id) - .map(|c| (c.column_id, c.position, c.title.clone())); - - if let Some((deleted_column_id, deleted_position, card_title)) = deleted_info { - // Execute ArchiveCard command - let cmd = Box::new(ArchiveCard { card_id }); - if let Err(e) = self.execute_command(cmd) { - tracing::error!("Failed to archive card: {}", e); - return false; - } - - // Compact positions in the deleted column to remove gaps - self.compact_column_positions(deleted_column_id); - - // Update selection to the next appropriate card - self.select_card_after_deletion(deleted_column_id, deleted_position); - - tracing::info!("Card '{}' archived", card_title); - return true; - } - false - } - pub fn compact_column_positions(&mut self, column_id: uuid::Uuid) { kanban_domain::card_lifecycle::compact_column_positions(&mut self.ctx.cards, column_id); } @@ -617,29 +586,6 @@ impl App { } } - #[allow(dead_code)] - fn restore_selected_cards(&mut self) { - let card_ids: Vec = self.selected_cards.iter().copied().collect(); - let mut restored_count = 0; - - for card_id in card_ids { - if let Some(archived_card) = self - .ctx - .archived_cards - .iter() - .find(|dc| dc.card.id == card_id) - .cloned() - { - self.restore_card(archived_card); - restored_count += 1; - } - } - - tracing::info!("Restored {} card(s)", restored_count); - self.selected_cards.clear(); - self.refresh_view(); - } - pub fn restore_card(&mut self, archived_card: ArchivedCard) { let card_id = archived_card.card.id; let original_column_id = archived_card.original_column_id; @@ -716,57 +662,6 @@ impl App { } } - #[allow(dead_code)] - fn permanent_delete_selected_cards(&mut self) { - let card_ids: Vec = self.selected_cards.iter().copied().collect(); - let mut deleted_count = 0; - - for card_id in card_ids { - if let Some(card) = self - .ctx - .archived_cards - .iter() - .find(|dc| dc.card.id == card_id) - { - let card_title = card.card.title.clone(); - - // Execute DeleteCard command - let cmd = Box::new(DeleteCard { card_id }); - if let Err(e) = self.execute_command(cmd) { - tracing::error!("Failed to permanently delete card: {}", e); - continue; - } - - tracing::info!("Permanently deleted card '{}'", card_title); - deleted_count += 1; - } - } - - tracing::info!("Permanently deleted {} card(s)", deleted_count); - self.selected_cards.clear(); - self.refresh_view(); - } - - #[allow(dead_code)] - fn permanent_delete_card_at(&mut self, index: usize) { - if index < self.ctx.archived_cards.len() { - if let Some(card) = self.ctx.archived_cards.get(index) { - let card_id = card.card.id; - let card_title = card.card.title.clone(); - - // Execute DeleteCard command - let cmd = Box::new(DeleteCard { card_id }); - if let Err(e) = self.execute_command(cmd) { - tracing::error!("Failed to permanently delete card: {}", e); - return; - } - - tracing::info!("Permanently deleted card '{}'", card_title); - self.refresh_view(); - } - } - } - pub fn handle_toggle_archived_cards_view(&mut self) { match self.mode { AppMode::Normal => { diff --git a/crates/kanban-tui/src/tui_context.rs b/crates/kanban-tui/src/tui_context.rs index dfd8d47f..04fe167c 100644 --- a/crates/kanban-tui/src/tui_context.rs +++ b/crates/kanban-tui/src/tui_context.rs @@ -182,6 +182,7 @@ impl KanbanOperations for TuiContext { board_id: Uuid, column_id: Uuid, title: String, + options: kanban_domain::CreateCardOptions, ) -> KanbanResult { let position = kanban_domain::card_lifecycle::next_position_in_column(&self.cards, column_id); @@ -190,6 +191,7 @@ impl KanbanOperations for TuiContext { column_id, title, position, + options, }); self.execute_command(cmd)?; Ok(self.cards.last().unwrap().clone()) diff --git a/crates/kanban-tui/tests/data_integrity_tests.rs b/crates/kanban-tui/tests/data_integrity_tests.rs index fa6a03ec..d92f7094 100644 --- a/crates/kanban-tui/tests/data_integrity_tests.rs +++ b/crates/kanban-tui/tests/data_integrity_tests.rs @@ -16,6 +16,7 @@ fn test_delete_card_cleans_dependencies() { column_id: columns[0].id, title: "Card A".to_string(), position: 0, + options: Default::default(), }; let mut ctx = CommandContext { boards: &mut boards, @@ -35,6 +36,7 @@ fn test_delete_card_cleans_dependencies() { column_id: columns[0].id, title: "Card B".to_string(), position: 1, + options: Default::default(), }; let mut ctx = CommandContext { boards: &mut boards, @@ -102,6 +104,7 @@ fn test_delete_column_with_cards_fails() { column_id: columns[0].id, title: "Test Card".to_string(), position: 0, + options: Default::default(), }; let mut ctx = CommandContext { boards: &mut boards, @@ -153,6 +156,7 @@ fn test_delete_column_with_archived_cards_fails() { column_id: columns[0].id, title: "Test Card".to_string(), position: 0, + options: Default::default(), }; let mut ctx = CommandContext { boards: &mut boards, @@ -222,6 +226,7 @@ fn test_delete_sprint_unassigns_cards() { column_id, title: "Card A".to_string(), position: 0, + options: Default::default(), }; let mut ctx = CommandContext { boards: &mut boards, @@ -241,6 +246,7 @@ fn test_delete_sprint_unassigns_cards() { column_id, title: "Card B".to_string(), position: 1, + options: Default::default(), }; let mut ctx = CommandContext { boards: &mut boards, @@ -360,6 +366,7 @@ fn test_archive_card_preserves_edges() { column_id, title: "Card A".to_string(), position: 0, + options: Default::default(), }; let mut ctx = CommandContext { boards: &mut boards, @@ -379,6 +386,7 @@ fn test_archive_card_preserves_edges() { column_id, title: "Card B".to_string(), position: 1, + options: Default::default(), }; let mut ctx = CommandContext { boards: &mut boards, @@ -507,6 +515,7 @@ fn test_cycle_detection_parent_child() { column_id, title: "Card A".to_string(), position: 0, + options: Default::default(), }; let mut ctx = CommandContext { boards: &mut boards, @@ -526,6 +535,7 @@ fn test_cycle_detection_parent_child() { column_id, title: "Card B".to_string(), position: 1, + options: Default::default(), }; let mut ctx = CommandContext { boards: &mut boards, @@ -545,6 +555,7 @@ fn test_cycle_detection_parent_child() { column_id, title: "Card C".to_string(), position: 2, + options: Default::default(), }; let mut ctx = CommandContext { boards: &mut boards, @@ -607,6 +618,7 @@ fn test_cycle_detection_blocks() { column_id, title: "Card A".to_string(), position: 0, + options: Default::default(), }; let mut ctx = CommandContext { boards: &mut boards, @@ -626,6 +638,7 @@ fn test_cycle_detection_blocks() { column_id, title: "Card B".to_string(), position: 1, + options: Default::default(), }; let mut ctx = CommandContext { boards: &mut boards, @@ -645,6 +658,7 @@ fn test_cycle_detection_blocks() { column_id, title: "Card C".to_string(), position: 2, + options: Default::default(), }; let mut ctx = CommandContext { boards: &mut boards, diff --git a/default.nix b/default.nix index 45641d26..747d736e 100644 --- a/default.nix +++ b/default.nix @@ -1,38 +1,27 @@ { lib, - fetchFromGitHub, rustPlatform, - nix-update-script, }: -rustPlatform.buildRustPackage (finalAttrs: { +let + cargoToml = lib.importTOML ./Cargo.toml; +in +rustPlatform.buildRustPackage { pname = "kanban"; - version = "0.1.15"; + inherit (cargoToml.workspace.package) version; - src = fetchFromGitHub { - owner = "fulsomenko"; - repo = "kanban"; - rev = "f81983111a0279958e8e6b407cc8d05c78015aeb"; - hash = "sha256-N5e+yzM8thYGmIcVpmF4aSE5ZscONwGgH0k1u68VS2U="; - }; - - GIT_COMMIT_HASH = finalAttrs.src.rev; + src = lib.cleanSource ./.; - cargoHash = "sha256-Q/o5MHjVRrJpfhkzNNJ6j4oASV5wDg/0Zi43zPlp5p8="; + cargoLock.lockFile = ./Cargo.lock; - passthru.updateScript = nix-update-script { }; + cargoBuildFlags = [ "--package" "kanban-cli" ]; + doCheck = false; meta = { - description = "Terminal-based project management solution"; - longDescription = '' - A terminal-based kanban/project management tool inspired by lazygit, - built with Rust. Features include file persistence, keyboard-driven - navigation, multi-select capabilities, and sprint management. - ''; - homepage = "https://github.com/fulsomenko/kanban"; + inherit (cargoToml.workspace.package) description homepage; license = lib.licenses.asl20; maintainers = with lib.maintainers; [ fulsomenko ]; mainProgram = "kanban"; platforms = lib.platforms.all; }; -}) +}