claude_code_acp/mcp/tools/
todo_write.rs

1//! TodoWrite tool for task list management
2//!
3//! Manages a structured task list for tracking progress during coding sessions.
4
5use 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/// Todo item status
15#[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/// A single todo item
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct TodoItem {
53    /// The task description
54    pub content: String,
55    /// Current status
56    pub status: String,
57    /// Active form of the description (shown when in progress)
58    #[serde(rename = "activeForm")]
59    pub active_form: String,
60}
61
62/// Input parameters for TodoWrite
63#[derive(Debug, Deserialize)]
64struct TodoWriteInput {
65    /// The updated todo list
66    todos: Vec<TodoItem>,
67}
68
69/// Shared todo list state
70#[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/// TodoWrite tool for task list management
117#[derive(Debug)]
118pub struct TodoWriteTool {
119    /// Shared todo list
120    todo_list: Arc<TodoList>,
121}
122
123impl Default for TodoWriteTool {
124    fn default() -> Self {
125        Self::new()
126    }
127}
128
129impl TodoWriteTool {
130    /// Create a new TodoWrite tool with its own list
131    pub fn new() -> Self {
132        Self {
133            todo_list: Arc::new(TodoList::new()),
134        }
135    }
136
137    /// Create a TodoWrite tool with a shared list
138    pub fn with_shared_list(list: Arc<TodoList>) -> Self {
139        Self { todo_list: list }
140    }
141
142    /// Get the shared todo list
143    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        // Parse input
196        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        // Validate todos
202        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            // Validate status
210            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        // Count statuses
222        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        // Update the todo list
239        self.todo_list.update(params.todos.clone()).await;
240
241        // Format output
242        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        // First create
324        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        // Then update to completed
339        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        // Update via tool1
429        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        // Verify via tool2's shared list
445        let items = tool2.todo_list().get_all().await;
446        assert_eq!(items.len(), 1);
447        assert_eq!(items[0].content, "Shared task");
448    }
449}