Files
mcp-memory/server/src/handlers/tasks.rs
T

883 lines
32 KiB
Rust

use crate::models::*;
use crate::router::McpTool;
use crate::state::MemoryState;
use crate::tools::*;
use async_trait::async_trait;
use serde_json::Value;
use std::sync::Arc;
pub struct AddTaskHandler;
#[async_trait]
impl McpTool for AddTaskHandler {
fn name(&self) -> &'static str {
"add_task"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<AddTaskTool>("add_task", "Execute add_task")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: AddTaskTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let now = crate::handlers::utils::now_secs();
let task_id = uuid::Uuid::new_v4().to_string();
let deps = req.dependencies.unwrap_or_default();
let task = Task {
id: task_id.clone(),
title: req.title,
status: "pending".to_string(),
description: req.description,
created_at: now,
updated_at: now,
git_branch: req.git_branch,
parent_id: req.parent_id,
dependencies: deps,
acceptance_criteria: vec![],
expires_at: None,
};
let idx = state.get_search_index();
drop(idx.index_task(&task));
state.project.tasks.modify(|tasks| {
tasks.push(task.clone());
});
state.record_activity("task_create", &format!("Created task: {}", task.title), Some(&task.description));
Ok(format!("Task added with ID: {}", task_id).to_string())
}
}
pub struct DeleteTaskHandler;
#[async_trait]
impl McpTool for DeleteTaskHandler {
fn name(&self) -> &'static str {
"delete_task"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<DeleteTaskTool>("delete_task", "Execute delete_task")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: DeleteTaskTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let mut deleted_count = 0;
let mut actually_deleted = Vec::new();
state.project.tasks.modify(|tasks| {
let initial_len = tasks.len();
// Build index-based children map
let mut children_map: std::collections::HashMap<usize, Vec<usize>> =
std::collections::HashMap::new();
let mut id_to_index = std::collections::HashMap::new();
for (idx, t) in tasks.iter().enumerate() {
id_to_index.insert(t.id.as_str(), idx);
}
for (idx, t) in tasks.iter().enumerate() {
if let Some(pid) = &t.parent_id
&& let Some(&parent_idx) = id_to_index.get(pid.as_str())
{
children_map.entry(parent_idx).or_default().push(idx);
}
}
let mut to_delete_idx = std::collections::HashSet::new();
if let Some(&start_idx) = id_to_index.get(req.id.as_str()) {
let mut queue = std::collections::VecDeque::new();
queue.push_back(start_idx);
while let Some(curr) = queue.pop_front() {
if to_delete_idx.insert(curr)
&& let Some(children) = children_map.get(&curr)
{
queue.extend(children.iter().copied());
}
}
}
for &idx in &to_delete_idx {
actually_deleted.push(tasks[idx].id.clone());
}
tasks.retain(|t| !actually_deleted.contains(&t.id));
deleted_count = initial_len - tasks.len();
});
if deleted_count > 0 {
let idx = state.get_search_index();
for id in actually_deleted {
drop(idx.delete_document(&id));
}
Ok(vec![
format!("Deleted task and its children ({} total).", deleted_count).to_string(),
][0]
.clone())
} else {
Err(crate::error::AppError::Internal("Task not found. Please use the list_active_tasks tool to verify the correct task ID.".to_string()))
}
}
}
pub struct UpdateTaskStatusHandler;
#[async_trait]
impl McpTool for UpdateTaskStatusHandler {
fn name(&self) -> &'static str {
"update_task_status"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<UpdateTaskStatusTool>(
"update_task_status",
"Execute update_task_status",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: UpdateTaskStatusTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let mut found = false;
let mut blocked = false;
let mut blocker_details = String::new();
let target_status = req.status.to_lowercase();
state.project.tasks.modify(|tasks| {
// Find target task
let target_idx = tasks
.iter()
.position(|t| t.id == req.id || t.title == req.id);
let target_idx = match target_idx {
Some(idx) => idx,
None => return,
};
found = true;
if target_status == "done" || target_status == "completed" {
// 1. Check Acceptance Criteria
if tasks[target_idx]
.acceptance_criteria
.iter()
.any(|c| !c.is_met)
{
blocked = true;
blocker_details = "Unmet acceptance criteria exist.".to_string();
}
// 2. Check dependencies
if !blocked {
let mut uncompleted_deps = Vec::new();
for dep_id in &tasks[target_idx].dependencies {
if let Some(dep_task) = tasks.iter().find(|dt| dt.id == *dep_id)
&& dep_task.status != "completed"
&& dep_task.status != "done"
{
uncompleted_deps.push(dep_task.title.as_str());
}
}
if !uncompleted_deps.is_empty() {
blocked = true;
blocker_details =
format!("Blocked by dependencies: {}", uncompleted_deps.join(", "));
}
}
// 3. Check child tasks
if !blocked {
let target_id_ref = tasks[target_idx].id.as_str();
let mut uncompleted_children = Vec::new();
for child in tasks
.iter()
.filter(|t| t.parent_id.as_deref() == Some(target_id_ref))
{
if child.status != "completed" && child.status != "done" {
uncompleted_children.push(child.title.as_str());
}
}
if !uncompleted_children.is_empty() {
blocked = true;
blocker_details = format!(
"Blocked by child tasks: {}",
uncompleted_children.join(", ")
);
}
}
}
if !blocked {
// Apply update
tasks[target_idx].status = target_status.clone();
tasks[target_idx].updated_at = crate::handlers::utils::now_secs();
// Cascade cancellation to children
if target_status == "cancelled" || target_status == "abandoned" {
let mut children_map: std::collections::HashMap<usize, Vec<usize>> =
std::collections::HashMap::new();
// First pass: map string ID to index to build the adjacency list by index
let mut id_to_idx = std::collections::HashMap::new();
for (idx, t) in tasks.iter().enumerate() {
id_to_idx.insert(t.id.as_str(), idx);
}
for (idx, t) in tasks.iter().enumerate() {
if let Some(pid) = &t.parent_id
&& let Some(&p_idx) = id_to_idx.get(pid.as_str())
{
children_map.entry(p_idx).or_default().push(idx);
}
}
if let Some(&start_idx) = id_to_idx.get(tasks[target_idx].id.as_str()) {
let mut queue = std::collections::VecDeque::new();
queue.push_back(start_idx);
while let Some(curr) = queue.pop_front() {
if let Some(child_indices) = children_map.get(&curr) {
for &idx in child_indices {
if tasks[idx].status != "completed"
&& tasks[idx].status != target_status
{
tasks[idx].status = target_status.clone();
queue.push_back(idx);
}
}
}
}
}
}
}
});
if blocked {
Err(crate::error::AppError::Internal(format!(
"Error: Cannot transition task. {}",
blocker_details
)))
} else if found {
state.record_activity("task_update", &format!("Task {} status -> {}", req.id, req.status), None);
Ok("Task status updated.".to_string())
} else {
Err(crate::error::AppError::Internal("Task not found. Please use the list_active_tasks tool to verify the correct task ID.".to_string()))
}
}
}
pub struct ListActiveTasksHandler;
#[async_trait]
impl McpTool for ListActiveTasksHandler {
fn name(&self) -> &'static str {
"list_active_tasks"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<ListActiveTasksTool>(
"list_active_tasks",
"Execute list_active_tasks",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: ListActiveTasksTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let level = req.summary_level.as_deref().unwrap_or("detailed");
let data = state.project.tasks.read_with(|tasks| {
let filtered: Vec<_> = tasks
.iter()
.filter(|t| {
let status_match = t.status != "done" && t.status != "completed";
let branch_match = match &req.git_branch {
Some(branch) => {
t.git_branch.is_none()
|| t.git_branch.as_deref() == Some(branch.as_str())
}
None => true,
};
status_match && branch_match
})
.map(|t| match level {
"compact" => serde_json::json!({
"id": t.id,
"title": t.title,
"status": t.status,
}),
"full" => serde_json::to_value(t).unwrap_or_default(),
_ => serde_json::json!({
"id": t.id,
"title": t.title,
"status": t.status,
"description": t.description,
"git_branch": t.git_branch,
}),
})
.collect();
let mut json_str = serde_json::to_string(&filtered)?;
if let Some(max_t) = req.max_tokens {
let char_limit = max_t * 4;
if json_str.len() > char_limit {
json_str.truncate(char_limit);
json_str.push_str(" ...[truncated due to max_tokens]");
}
}
Ok::<String, crate::error::AppError>(json_str)
})?;
Ok(data)
}
}
pub struct SetAcceptanceCriteriaHandler;
#[async_trait]
impl McpTool for SetAcceptanceCriteriaHandler {
fn name(&self) -> &'static str {
"set_acceptance_criteria"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<SetAcceptanceCriteriaTool>(
"set_acceptance_criteria",
"Execute set_acceptance_criteria",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: SetAcceptanceCriteriaTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let mut success = false;
state.project.tasks.modify(|tasks| {
if let Some(task) = tasks.iter_mut().rev().find(|t| t.title == req.task_title) {
task.acceptance_criteria = req
.criteria
.into_iter()
.map(|desc| crate::models::AcceptanceCriteria {
id: uuid::Uuid::new_v4().to_string(),
description: desc,
is_met: false,
})
.collect();
task.updated_at = crate::handlers::utils::now_secs();
success = true;
}
});
if success {
Ok("Acceptance criteria set successfully.".to_string())
} else {
Err(crate::error::AppError::Internal("Task not found. Please use the list_active_tasks tool to verify the correct task ID.".to_string()))
}
}
}
pub struct VerifyAcceptanceCriteriaHandler;
#[async_trait]
impl McpTool for VerifyAcceptanceCriteriaHandler {
fn name(&self) -> &'static str {
"verify_acceptance_criteria"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<VerifyAcceptanceCriteriaTool>(
"verify_acceptance_criteria",
"Execute verify_acceptance_criteria",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: VerifyAcceptanceCriteriaTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let mut success = false;
let mut already_met = false;
state.project.tasks.modify(|tasks| {
if let Some(task) = tasks.iter_mut().find(|t| t.id == req.task_id)
&& let Some(ac) = task
.acceptance_criteria
.iter_mut()
.find(|c| c.id == req.criteria || c.description == req.criteria)
{
if ac.is_met {
already_met = true;
} else {
ac.is_met = true;
success = true;
task.updated_at = crate::handlers::utils::now_secs();
}
}
});
if success {
Ok(format!(
"Acceptance criteria verified with proof: {}",
req.proof
))
} else if already_met {
Ok("Acceptance criteria was already met.".to_string())
} else {
Err(crate::error::AppError::Internal(
"Acceptance criteria or task not found.".to_string(),
))
}
}
}
pub struct AddMilestoneHandler;
#[async_trait]
impl McpTool for AddMilestoneHandler {
fn name(&self) -> &'static str {
"add_milestone"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<AddMilestoneTool>("add_milestone", "Execute add_milestone")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: AddMilestoneTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
state.project.milestones.modify(|ms| {
ms.push(crate::models::Milestone {
id: uuid::Uuid::new_v4().to_string(),
title: req.title,
status: "pending".to_string(),
namespace: req.namespace,
target_date: None,
})
});
Ok("Milestone added".to_string())
}
}
pub struct UpdateMilestoneHandler;
#[async_trait]
impl McpTool for UpdateMilestoneHandler {
fn name(&self) -> &'static str {
"update_milestone"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<UpdateMilestoneTool>("update_milestone", "Execute update_milestone")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: UpdateMilestoneTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let mut found = false;
state.project.milestones.modify(|ms| {
for m in ms.iter_mut() {
if m.id == req.id {
m.status = req.status.clone();
found = true;
break;
}
}
});
if found {
Ok("Milestone updated".to_string())
} else {
Err(crate::error::AppError::Internal(
"Milestone not found. Please verify the milestone ID using list_milestones."
.to_string(),
))
}
}
}
pub struct ListMilestonesHandler;
#[async_trait]
impl McpTool for ListMilestonesHandler {
fn name(&self) -> &'static str {
"list_milestones"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<ListMilestonesTool>("list_milestones", "Execute list_milestones")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: ListMilestonesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let data = state.project.milestones.read_with(|items| {
let filtered: Vec<_> = items
.iter()
.filter(|i| {
if let Some(ns) = &req.namespace {
&i.namespace == ns
} else {
true
}
})
.collect();
Ok::<String, crate::error::AppError>(serde_json::to_string(&filtered)?)
})?;
Ok(data)
}
}
pub struct TasksHandler;
#[async_trait]
impl McpTool for TasksHandler {
fn name(&self) -> &'static str {
"tasks"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<TasksTool>(
"tasks",
"Consolidated task management (add, update, delete, list, set_criteria, verify)",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: TasksTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
match req.action {
TaskAction::Add => {
let title = req.title.ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'title' for action 'add'. Next step: Provide non-empty 'title' string in request and retry.".to_string())
})?;
let description = req.description.unwrap_or_default();
let add_args = serde_json::json!({
"title": title,
"description": description,
"git_branch": req.git_branch,
"parent_id": req.parent_id,
"dependencies": req.dependencies,
});
AddTaskHandler.execute(add_args, state).await
}
TaskAction::Update => {
let id = req.id.ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'id' for action 'update'. Next step: Provide task 'id' string in request and retry.".to_string())
})?;
let status = req.status.ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'status' for action 'update'. Next step: Provide valid 'status' ('pending', 'completed', or 'cancelled') in request and retry.".to_string())
})?;
let update_args = serde_json::json!({
"id": id,
"status": status,
});
UpdateTaskStatusHandler.execute(update_args, state).await
}
TaskAction::Delete => {
let id = req.id.ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'id' for action 'delete'. Next step: Provide task 'id' string in request and retry.".to_string())
})?;
let del_args = serde_json::json!({
"id": id,
});
DeleteTaskHandler.execute(del_args, state).await
}
TaskAction::List => {
let list_args = serde_json::json!({
"git_branch": req.git_branch,
"summary_level": req.summary_level,
"max_tokens": req.max_tokens,
});
ListActiveTasksHandler.execute(list_args, state).await
}
TaskAction::SetCriteria => {
let id = req.id.ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'id' for action 'set_criteria'. Next step: Provide task 'id' string in request and retry.".to_string())
})?;
let criteria = req.criteria.ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'criteria' for action 'set_criteria'. Next step: Provide array of acceptance criteria descriptions in request and retry.".to_string())
})?;
let set_args = serde_json::json!({
"id": id,
"acceptance_criteria": criteria,
});
SetAcceptanceCriteriaHandler.execute(set_args, state).await
}
TaskAction::Verify => {
let id = req.id.ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'id' for action 'verify'. Next step: Provide task 'id' string in request and retry.".to_string())
})?;
let verify_args = serde_json::json!({
"id": id,
});
VerifyAcceptanceCriteriaHandler.execute(verify_args, state).await
}
}
}
}
pub struct MilestonesHandler;
#[async_trait]
impl McpTool for MilestonesHandler {
fn name(&self) -> &'static str {
"milestones"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<MilestonesTool>(
"milestones",
"Consolidated milestone management (add, update, list)",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: MilestonesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
match req.action {
MilestoneAction::Add => {
let title = req.title.ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'title' for action 'add'. Next step: Provide non-empty 'title' string in request and retry.".to_string())
})?;
let add_args = serde_json::json!({
"title": title,
"namespace": req.namespace.unwrap_or_else(|| crate::models::default_namespace()),
});
AddMilestoneHandler.execute(add_args, state).await
}
MilestoneAction::Update => {
let id = req.id.ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'id' for action 'update'. Next step: Provide milestone 'id' string in request and retry.".to_string())
})?;
let status = req.status.ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'status' for action 'update'. Next step: Provide milestone 'status' in request and retry.".to_string())
})?;
let update_args = serde_json::json!({
"id": id,
"status": status,
});
UpdateMilestoneHandler.execute(update_args, state).await
}
MilestoneAction::List => {
let list_args = serde_json::json!({
"namespace": req.namespace,
});
ListMilestonesHandler.execute(list_args, state).await
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::tempdir;
#[tokio::test]
async fn test_add_task_and_list() {
let dir = tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let add_handler = AddTaskHandler;
let args = json!({
"title": "Fix the hyperdrive",
"description": "It's making a strange noise",
"acceptance_criteria": ["Stop the noise", "Reach lightspeed"],
});
let res = add_handler
.execute(args, state.clone())
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert!(res.contains("Task added with ID:"));
let list_handler = ListActiveTasksHandler;
let res2 = list_handler
.execute(json!({}), state.clone())
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert!(res2.contains("Fix the hyperdrive"));
}
#[tokio::test]
async fn test_update_task_status() {
let dir = tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let add_handler = AddTaskHandler;
let res = add_handler
.execute(
json!({"title": "Test", "description": "test"}),
state.clone(),
)
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
let id_start = res.find("ID: ").unwrap() + 4;
let task_id = res[id_start..].trim();
let update_handler = UpdateTaskStatusHandler;
let args = json!({
"id": task_id,
"status": "done"
});
let res3 = update_handler
.execute(args, state.clone())
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert_eq!(res3, "Task status updated.");
let list_handler = ListActiveTasksHandler;
let res4 = list_handler
.execute(json!({}), state.clone())
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert!(!res4.contains(task_id));
}
#[tokio::test]
async fn test_milestones_and_criteria() {
let dir = tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
// Add Milestone
let add_milestone = AddMilestoneHandler;
let args_ms = json!({
"name": "v1.0",
"title": "Release 1.0",
"description": "First release",
"target_date": 1700000000,
"end_date": 1700000000,
"namespace": "global"
});
let res1 = add_milestone
.execute(args_ms, state.clone())
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert!(res1.contains("Milestone added"));
// Fetch milestone ID from state directly to update
let ms_id = state.project.milestones.read_with(|ms| ms[0].id.clone());
// Update Milestone
let update_ms = UpdateMilestoneHandler;
let args_ums = json!({
"id": ms_id,
"status": "completed"
});
let res2 = update_ms
.execute(args_ums, state.clone())
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert_eq!(res2, "Milestone updated");
// List Milestones
let list_ms = ListMilestonesHandler;
let res3 = list_ms
.execute(json!({"namespace": "global"}), state.clone())
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert!(res3.contains("completed"));
assert!(res3.contains("Release 1.0"));
// Task Acceptance Criteria
let add_task = AddTaskHandler;
let res_task = add_task
.execute(
json!({"title": "Test", "description": "desc"}),
state.clone(),
)
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
let task_id = res_task[res_task.find("ID: ").unwrap() + 4..].trim();
let set_ac = SetAcceptanceCriteriaHandler;
let args_ac = json!({
"task_id": task_id,
"task_title": "Test",
"criteria": ["Do X", "Do Y"]
});
let res4 = set_ac
.execute(args_ac, state.clone())
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert_eq!(res4, "Acceptance criteria set successfully.");
let verify_ac = VerifyAcceptanceCriteriaHandler;
let args_vac = json!({
"task_id": task_id,
"criteria": "Do X",
"proof": "I did X"
});
let res5 = verify_ac
.execute(args_vac, state.clone())
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert!(res5.contains("Acceptance criteria verified"));
}
#[tokio::test]
async fn test_delete_task_with_children() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let add_task = AddTaskHandler;
let parent = add_task
.execute(
json!({"title": "Parent", "description": "p"}),
state.clone(),
)
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
let parent_id = parent[parent.find("ID: ").unwrap() + 4..]
.trim()
.to_string();
let child = add_task
.execute(
json!({"title": "Child", "description": "c", "parent_id": parent_id}),
state.clone(),
)
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
let _child_id = child[child.find("ID: ").unwrap() + 4..].trim().to_string();
let del_task = DeleteTaskHandler;
let res_del = del_task
.execute(json!({"id": parent_id}), state.clone())
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert!(res_del.contains("Deleted task and its children (2 total)."));
}
#[tokio::test]
async fn test_list_milestones_with_namespace() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let add_milestone = AddMilestoneHandler;
let args_ms = serde_json::json!({
"name": "v1.0",
"title": "Release 1.0",
"description": "First release",
"target_date": 1700000000,
"end_date": 1700000000,
"namespace": "global"
});
let res1 = add_milestone
.execute(args_ms, state.clone())
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert!(res1.contains("Milestone added"));
let list_ms = ListMilestonesHandler;
let res2 = list_ms
.execute(serde_json::json!({"namespace": "global"}), state.clone())
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert!(res2.contains("Release 1.0"));
}
}