1use std::fmt::Write;
20use std::path::PathBuf;
21use std::sync::Arc;
22
23use crate::{Tool, ToolContext, ToolResult, ToolTier};
24use anyhow::{Context, Result};
25use async_trait::async_trait;
26use serde::{Deserialize, Serialize};
27use serde_json::{Value, json};
28use tokio::sync::RwLock;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum TodoStatus {
34 Pending,
36 InProgress,
38 Completed,
40}
41
42impl TodoStatus {
43 #[must_use]
45 pub const fn icon(&self) -> &'static str {
46 match self {
47 Self::Pending => "○",
48 Self::InProgress => "⚡",
49 Self::Completed => "✓",
50 }
51 }
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct TodoItem {
57 pub content: String,
59 pub status: TodoStatus,
61 pub active_form: String,
63}
64
65impl TodoItem {
66 #[must_use]
68 pub fn new(content: impl Into<String>, active_form: impl Into<String>) -> Self {
69 Self {
70 content: content.into(),
71 status: TodoStatus::Pending,
72 active_form: active_form.into(),
73 }
74 }
75
76 #[must_use]
78 pub fn with_status(
79 content: impl Into<String>,
80 active_form: impl Into<String>,
81 status: TodoStatus,
82 ) -> Self {
83 Self {
84 content: content.into(),
85 status,
86 active_form: active_form.into(),
87 }
88 }
89
90 #[must_use]
92 pub const fn icon(&self) -> &'static str {
93 self.status.icon()
94 }
95}
96
97#[derive(Debug, Default)]
99pub struct TodoState {
100 pub items: Vec<TodoItem>,
102 storage_path: Option<PathBuf>,
104}
105
106impl TodoState {
107 #[must_use]
109 pub const fn new() -> Self {
110 Self {
111 items: Vec::new(),
112 storage_path: None,
113 }
114 }
115
116 #[must_use]
118 pub const fn with_storage(path: PathBuf) -> Self {
119 Self {
120 items: Vec::new(),
121 storage_path: Some(path),
122 }
123 }
124
125 pub fn set_storage_path(&mut self, path: PathBuf) {
127 self.storage_path = Some(path);
128 }
129
130 pub fn load(&mut self) -> Result<()> {
136 if let Some(ref path) = self.storage_path.as_ref().filter(|p| p.exists()) {
137 let content = std::fs::read_to_string(path).context("Failed to read todos file")?;
138 self.items = serde_json::from_str(&content).context("Failed to parse todos file")?;
139 }
140 Ok(())
141 }
142
143 pub fn save(&self) -> Result<()> {
149 if let Some(ref path) = self.storage_path {
150 if let Some(parent) = path.parent() {
152 std::fs::create_dir_all(parent).context("Failed to create todos directory")?;
153 }
154 let content =
155 serde_json::to_string_pretty(&self.items).context("Failed to serialize todos")?;
156 std::fs::write(path, content).context("Failed to write todos file")?;
157 }
158 Ok(())
159 }
160
161 pub fn set_items(&mut self, items: Vec<TodoItem>) {
163 self.items = items;
164 }
165
166 pub fn add(&mut self, item: TodoItem) {
168 self.items.push(item);
169 }
170
171 #[must_use]
173 pub fn count_by_status(&self) -> (usize, usize, usize) {
174 let pending = self
175 .items
176 .iter()
177 .filter(|i| i.status == TodoStatus::Pending)
178 .count();
179 let in_progress = self
180 .items
181 .iter()
182 .filter(|i| i.status == TodoStatus::InProgress)
183 .count();
184 let completed = self
185 .items
186 .iter()
187 .filter(|i| i.status == TodoStatus::Completed)
188 .count();
189 (pending, in_progress, completed)
190 }
191
192 #[must_use]
194 pub fn current_task(&self) -> Option<&TodoItem> {
195 self.items
196 .iter()
197 .find(|i| i.status == TodoStatus::InProgress)
198 }
199
200 #[must_use]
202 pub fn format_display(&self) -> String {
203 if self.items.is_empty() {
204 return "No tasks".to_string();
205 }
206
207 let (_pending, in_progress, completed) = self.count_by_status();
208 let total = self.items.len();
209
210 let mut output = format!("TODO ({completed}/{total})");
211
212 if in_progress > 0
213 && let Some(current) = self.current_task()
214 {
215 let _ = write!(output, " - {}", current.active_form);
216 }
217
218 output.push('\n');
219
220 for item in &self.items {
221 let _ = writeln!(output, " {} {}", item.icon(), item.content);
222 }
223
224 output
225 }
226
227 #[must_use]
229 pub const fn is_empty(&self) -> bool {
230 self.items.is_empty()
231 }
232
233 #[must_use]
235 pub const fn len(&self) -> usize {
236 self.items.len()
237 }
238}
239
240pub struct TodoWriteTool {
242 state: Arc<RwLock<TodoState>>,
244}
245
246impl TodoWriteTool {
247 #[must_use]
249 pub const fn new(state: Arc<RwLock<TodoState>>) -> Self {
250 Self { state }
251 }
252}
253
254#[derive(Debug, Deserialize)]
256struct TodoItemInput {
257 content: String,
258 status: TodoStatus,
259 #[serde(rename = "activeForm")]
260 active_form: String,
261}
262
263#[derive(Debug, Deserialize)]
265struct TodoWriteInput {
266 todos: Vec<TodoItemInput>,
267}
268
269#[async_trait]
270impl<Ctx: Send + Sync + 'static> Tool<Ctx> for TodoWriteTool {
271 fn name(&self) -> &'static str {
272 "todo_write"
273 }
274
275 fn description(&self) -> &'static str {
276 "Update the TODO list to track tasks and show progress to the user. \
277 Use this tool frequently to plan complex tasks and mark progress. \
278 Each item needs 'content' (imperative form like 'Fix the bug'), \
279 'status' (pending/in_progress/completed), and 'activeForm' \
280 (present continuous like 'Fixing the bug'). \
281 Mark tasks completed immediately when done - don't batch completions."
282 }
283
284 fn input_schema(&self) -> Value {
285 json!({
286 "type": "object",
287 "required": ["todos"],
288 "properties": {
289 "todos": {
290 "type": "array",
291 "description": "The complete TODO list (replaces existing)",
292 "items": {
293 "type": "object",
294 "required": ["content", "status", "activeForm"],
295 "properties": {
296 "content": {
297 "type": "string",
298 "description": "Task description in imperative form (e.g., 'Fix the bug')"
299 },
300 "status": {
301 "type": "string",
302 "enum": ["pending", "in_progress", "completed"],
303 "description": "Current status of the task"
304 },
305 "activeForm": {
306 "type": "string",
307 "description": "Present continuous form shown during execution (e.g., 'Fixing the bug')"
308 }
309 }
310 }
311 }
312 }
313 })
314 }
315
316 fn tier(&self) -> ToolTier {
317 ToolTier::Observe }
319
320 async fn execute(&self, _ctx: &ToolContext<Ctx>, input: Value) -> Result<ToolResult> {
321 let input: TodoWriteInput =
322 serde_json::from_value(input).context("Invalid input for todo_write")?;
323
324 let items: Vec<TodoItem> = input
325 .todos
326 .into_iter()
327 .map(|t| TodoItem {
328 content: t.content,
329 status: t.status,
330 active_form: t.active_form,
331 })
332 .collect();
333
334 let display = {
335 let mut state = self.state.write().await;
336 state.set_items(items);
337
338 if let Err(e) = state.save() {
340 tracing::warn!("Failed to save todos: {e}");
341 }
342
343 state.format_display()
344 };
345
346 Ok(ToolResult::success(format!(
347 "TODO list updated.\n\n{display}"
348 )))
349 }
350}
351
352pub struct TodoReadTool {
354 state: Arc<RwLock<TodoState>>,
356}
357
358impl TodoReadTool {
359 #[must_use]
361 pub const fn new(state: Arc<RwLock<TodoState>>) -> Self {
362 Self { state }
363 }
364}
365
366#[async_trait]
367impl<Ctx: Send + Sync + 'static> Tool<Ctx> for TodoReadTool {
368 fn name(&self) -> &'static str {
369 "todo_read"
370 }
371
372 fn description(&self) -> &'static str {
373 "Read the current TODO list to see task status and progress."
374 }
375
376 fn input_schema(&self) -> Value {
377 json!({
378 "type": "object",
379 "properties": {}
380 })
381 }
382
383 fn tier(&self) -> ToolTier {
384 ToolTier::Observe
385 }
386
387 async fn execute(&self, _ctx: &ToolContext<Ctx>, _input: Value) -> Result<ToolResult> {
388 let display = {
389 let state = self.state.read().await;
390 state.format_display()
391 };
392
393 Ok(ToolResult::success(display))
394 }
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400
401 #[test]
402 fn test_todo_status_icons() {
403 assert_eq!(TodoStatus::Pending.icon(), "○");
404 assert_eq!(TodoStatus::InProgress.icon(), "⚡");
405 assert_eq!(TodoStatus::Completed.icon(), "✓");
406 }
407
408 #[test]
409 fn test_todo_item_new() {
410 let item = TodoItem::new("Fix the bug", "Fixing the bug");
411 assert_eq!(item.content, "Fix the bug");
412 assert_eq!(item.active_form, "Fixing the bug");
413 assert_eq!(item.status, TodoStatus::Pending);
414 }
415
416 #[test]
417 fn test_todo_state_count_by_status() {
418 let mut state = TodoState::new();
419 state.add(TodoItem::with_status(
420 "Task 1",
421 "Task 1",
422 TodoStatus::Pending,
423 ));
424 state.add(TodoItem::with_status(
425 "Task 2",
426 "Task 2",
427 TodoStatus::InProgress,
428 ));
429 state.add(TodoItem::with_status(
430 "Task 3",
431 "Task 3",
432 TodoStatus::Completed,
433 ));
434 state.add(TodoItem::with_status(
435 "Task 4",
436 "Task 4",
437 TodoStatus::Completed,
438 ));
439
440 let (pending, in_progress, completed) = state.count_by_status();
441 assert_eq!(pending, 1);
442 assert_eq!(in_progress, 1);
443 assert_eq!(completed, 2);
444 }
445
446 #[test]
447 fn test_todo_state_current_task() {
448 let mut state = TodoState::new();
449 state.add(TodoItem::with_status(
450 "Task 1",
451 "Task 1",
452 TodoStatus::Pending,
453 ));
454 assert!(state.current_task().is_none());
455
456 state.add(TodoItem::with_status(
457 "Task 2",
458 "Working on Task 2",
459 TodoStatus::InProgress,
460 ));
461 let current = state.current_task().unwrap();
462 assert_eq!(current.content, "Task 2");
463 assert_eq!(current.active_form, "Working on Task 2");
464 }
465
466 #[test]
467 fn test_todo_state_format_display() {
468 let mut state = TodoState::new();
469 assert_eq!(state.format_display(), "No tasks");
470
471 state.add(TodoItem::with_status(
472 "Fix bug",
473 "Fixing bug",
474 TodoStatus::InProgress,
475 ));
476 state.add(TodoItem::with_status(
477 "Write tests",
478 "Writing tests",
479 TodoStatus::Pending,
480 ));
481
482 let display = state.format_display();
483 assert!(display.contains("TODO (0/2)"));
484 assert!(display.contains("Fixing bug"));
485 assert!(display.contains("⚡ Fix bug"));
486 assert!(display.contains("○ Write tests"));
487 }
488
489 #[test]
490 fn test_todo_status_serde() {
491 let status = TodoStatus::InProgress;
492 let json = serde_json::to_string(&status).unwrap();
493 assert_eq!(json, "\"in_progress\"");
494
495 let parsed: TodoStatus = serde_json::from_str("\"completed\"").unwrap();
496 assert_eq!(parsed, TodoStatus::Completed);
497 }
498}