claude_code_acp/mcp/tools/
todo_write.rs1use async_trait::async_trait;
6use serde::{Deserialize, Serialize};
7use serde_json::{Value, json};
8use std::sync::Arc;
9use tokio::sync::RwLock;
10
11use super::base::Tool;
12use crate::mcp::registry::{ToolContext, ToolResult};
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum TodoStatus {
18 Pending,
19 InProgress,
20 Completed,
21}
22
23impl TodoStatus {
24 fn from_str(s: &str) -> Self {
25 match s {
26 "in_progress" => Self::InProgress,
27 "completed" => Self::Completed,
28 _ => Self::Pending,
29 }
30 }
31
32 #[allow(dead_code)]
33 fn as_str(&self) -> &'static str {
34 match self {
35 Self::Pending => "pending",
36 Self::InProgress => "in_progress",
37 Self::Completed => "completed",
38 }
39 }
40
41 fn symbol(&self) -> &'static str {
42 match self {
43 Self::Pending => "○",
44 Self::InProgress => "◐",
45 Self::Completed => "●",
46 }
47 }
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct TodoItem {
53 pub content: String,
55 pub status: String,
57 #[serde(rename = "activeForm")]
59 pub active_form: String,
60}
61
62#[derive(Debug, Deserialize)]
64struct TodoWriteInput {
65 todos: Vec<TodoItem>,
67}
68
69#[derive(Debug, Default)]
71pub struct TodoList {
72 items: RwLock<Vec<TodoItem>>,
73}
74
75impl TodoList {
76 pub fn new() -> Self {
77 Self {
78 items: RwLock::new(Vec::new()),
79 }
80 }
81
82 pub async fn update(&self, items: Vec<TodoItem>) {
83 let mut guard = self.items.write().await;
84 *guard = items;
85 }
86
87 pub async fn get_all(&self) -> Vec<TodoItem> {
88 self.items.read().await.clone()
89 }
90
91 pub async fn format(&self) -> String {
92 let items = self.items.read().await;
93 if items.is_empty() {
94 return "No todos".to_string();
95 }
96
97 let mut output = String::new();
98 for (i, item) in items.iter().enumerate() {
99 let status = TodoStatus::from_str(&item.status);
100 let display_text = if status == TodoStatus::InProgress {
101 &item.active_form
102 } else {
103 &item.content
104 };
105 output.push_str(&format!(
106 "{}. {} {}\n",
107 i + 1,
108 status.symbol(),
109 display_text
110 ));
111 }
112 output
113 }
114}
115
116#[derive(Debug)]
118pub struct TodoWriteTool {
119 todo_list: Arc<TodoList>,
121}
122
123impl Default for TodoWriteTool {
124 fn default() -> Self {
125 Self::new()
126 }
127}
128
129impl TodoWriteTool {
130 pub fn new() -> Self {
132 Self {
133 todo_list: Arc::new(TodoList::new()),
134 }
135 }
136
137 pub fn with_shared_list(list: Arc<TodoList>) -> Self {
139 Self { todo_list: list }
140 }
141
142 pub fn todo_list(&self) -> Arc<TodoList> {
144 self.todo_list.clone()
145 }
146}
147
148#[async_trait]
149impl Tool for TodoWriteTool {
150 fn name(&self) -> &str {
151 "TodoWrite"
152 }
153
154 fn description(&self) -> &str {
155 "Manages a structured task list for tracking progress. Use this to plan tasks, \
156 track progress, and demonstrate thoroughness. Each todo has content, status \
157 (pending/in_progress/completed), and activeForm (shown when in progress)."
158 }
159
160 fn input_schema(&self) -> Value {
161 json!({
162 "type": "object",
163 "required": ["todos"],
164 "properties": {
165 "todos": {
166 "type": "array",
167 "description": "The updated todo list",
168 "items": {
169 "type": "object",
170 "required": ["content", "status", "activeForm"],
171 "properties": {
172 "content": {
173 "type": "string",
174 "minLength": 1,
175 "description": "The task description (imperative form)"
176 },
177 "status": {
178 "type": "string",
179 "enum": ["pending", "in_progress", "completed"],
180 "description": "Current status of the task"
181 },
182 "activeForm": {
183 "type": "string",
184 "minLength": 1,
185 "description": "Present continuous form shown during execution"
186 }
187 }
188 }
189 }
190 }
191 })
192 }
193
194 async fn execute(&self, input: Value, _context: &ToolContext) -> ToolResult {
195 let params: TodoWriteInput = match serde_json::from_value(input) {
197 Ok(p) => p,
198 Err(e) => return ToolResult::error(format!("Invalid input: {}", e)),
199 };
200
201 for (i, todo) in params.todos.iter().enumerate() {
203 if todo.content.trim().is_empty() {
204 return ToolResult::error(format!("Todo {} has empty content", i + 1));
205 }
206 if todo.active_form.trim().is_empty() {
207 return ToolResult::error(format!("Todo {} has empty activeForm", i + 1));
208 }
209 let valid_statuses = ["pending", "in_progress", "completed"];
211 if !valid_statuses.contains(&todo.status.as_str()) {
212 return ToolResult::error(format!(
213 "Todo {} has invalid status '{}'. Must be one of: {:?}",
214 i + 1,
215 todo.status,
216 valid_statuses
217 ));
218 }
219 }
220
221 let pending = params
223 .todos
224 .iter()
225 .filter(|t| t.status == "pending")
226 .count();
227 let in_progress = params
228 .todos
229 .iter()
230 .filter(|t| t.status == "in_progress")
231 .count();
232 let completed = params
233 .todos
234 .iter()
235 .filter(|t| t.status == "completed")
236 .count();
237
238 self.todo_list.update(params.todos.clone()).await;
240
241 let formatted = self.todo_list.format().await;
243
244 let output = format!(
245 "Todos updated successfully.\n\n{}\n\nSummary: {} pending, {} in progress, {} completed",
246 formatted, pending, in_progress, completed
247 );
248
249 ToolResult::success(output).with_metadata(json!({
250 "total": params.todos.len(),
251 "pending": pending,
252 "in_progress": in_progress,
253 "completed": completed
254 }))
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261 use tempfile::TempDir;
262
263 #[test]
264 fn test_todo_write_tool_properties() {
265 let tool = TodoWriteTool::new();
266 assert_eq!(tool.name(), "TodoWrite");
267 assert!(tool.description().contains("task"));
268 }
269
270 #[test]
271 fn test_todo_write_input_schema() {
272 let tool = TodoWriteTool::new();
273 let schema = tool.input_schema();
274
275 assert_eq!(schema["type"], "object");
276 assert!(schema["properties"]["todos"].is_object());
277 assert!(
278 schema["required"]
279 .as_array()
280 .unwrap()
281 .contains(&json!("todos"))
282 );
283 }
284
285 #[tokio::test]
286 async fn test_todo_write_create_list() {
287 let temp_dir = TempDir::new().unwrap();
288 let tool = TodoWriteTool::new();
289 let context = ToolContext::new("test", temp_dir.path());
290
291 let result = tool
292 .execute(
293 json!({
294 "todos": [
295 {
296 "content": "Implement feature",
297 "status": "in_progress",
298 "activeForm": "Implementing feature"
299 },
300 {
301 "content": "Write tests",
302 "status": "pending",
303 "activeForm": "Writing tests"
304 }
305 ]
306 }),
307 &context,
308 )
309 .await;
310
311 assert!(!result.is_error);
312 assert!(result.content.contains("Todos updated"));
313 assert!(result.content.contains("1 pending"));
314 assert!(result.content.contains("1 in progress"));
315 }
316
317 #[tokio::test]
318 async fn test_todo_write_update_status() {
319 let temp_dir = TempDir::new().unwrap();
320 let tool = TodoWriteTool::new();
321 let context = ToolContext::new("test", temp_dir.path());
322
323 tool.execute(
325 json!({
326 "todos": [
327 {
328 "content": "Task 1",
329 "status": "pending",
330 "activeForm": "Doing task 1"
331 }
332 ]
333 }),
334 &context,
335 )
336 .await;
337
338 let result = tool
340 .execute(
341 json!({
342 "todos": [
343 {
344 "content": "Task 1",
345 "status": "completed",
346 "activeForm": "Doing task 1"
347 }
348 ]
349 }),
350 &context,
351 )
352 .await;
353
354 assert!(!result.is_error);
355 assert!(result.content.contains("1 completed"));
356 }
357
358 #[tokio::test]
359 async fn test_todo_write_invalid_status() {
360 let temp_dir = TempDir::new().unwrap();
361 let tool = TodoWriteTool::new();
362 let context = ToolContext::new("test", temp_dir.path());
363
364 let result = tool
365 .execute(
366 json!({
367 "todos": [
368 {
369 "content": "Task",
370 "status": "invalid_status",
371 "activeForm": "Doing task"
372 }
373 ]
374 }),
375 &context,
376 )
377 .await;
378
379 assert!(result.is_error);
380 assert!(result.content.contains("invalid status"));
381 }
382
383 #[tokio::test]
384 async fn test_todo_write_empty_content() {
385 let temp_dir = TempDir::new().unwrap();
386 let tool = TodoWriteTool::new();
387 let context = ToolContext::new("test", temp_dir.path());
388
389 let result = tool
390 .execute(
391 json!({
392 "todos": [
393 {
394 "content": "",
395 "status": "pending",
396 "activeForm": "Doing task"
397 }
398 ]
399 }),
400 &context,
401 )
402 .await;
403
404 assert!(result.is_error);
405 assert!(result.content.contains("empty content"));
406 }
407
408 #[test]
409 fn test_todo_status() {
410 assert_eq!(TodoStatus::from_str("pending"), TodoStatus::Pending);
411 assert_eq!(TodoStatus::from_str("in_progress"), TodoStatus::InProgress);
412 assert_eq!(TodoStatus::from_str("completed"), TodoStatus::Completed);
413 assert_eq!(TodoStatus::from_str("unknown"), TodoStatus::Pending);
414
415 assert_eq!(TodoStatus::Pending.as_str(), "pending");
416 assert_eq!(TodoStatus::InProgress.symbol(), "◐");
417 }
418
419 #[tokio::test]
420 async fn test_shared_todo_list() {
421 let shared_list = Arc::new(TodoList::new());
422 let tool1 = TodoWriteTool::with_shared_list(shared_list.clone());
423 let tool2 = TodoWriteTool::with_shared_list(shared_list.clone());
424
425 let temp_dir = TempDir::new().unwrap();
426 let context = ToolContext::new("test", temp_dir.path());
427
428 tool1
430 .execute(
431 json!({
432 "todos": [
433 {
434 "content": "Shared task",
435 "status": "pending",
436 "activeForm": "Doing shared task"
437 }
438 ]
439 }),
440 &context,
441 )
442 .await;
443
444 let items = tool2.todo_list().get_all().await;
446 assert_eq!(items.len(), 1);
447 assert_eq!(items[0].content, "Shared task");
448 }
449}