Skip to main content

codetether_agent/tool/
todo.rs

1//! Todo Tool - Read and write todo items for task tracking.
2
3use super::{Tool, ToolResult};
4use anyhow::{Context, Result};
5use async_trait::async_trait;
6use serde::{Deserialize, Serialize};
7use serde_json::{Value, json};
8use std::path::PathBuf;
9
10const TODO_FILE: &str = ".codetether-todos.json";
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct TodoItem {
14    pub id: String,
15    pub content: String,
16    pub status: TodoStatus,
17    #[serde(default)]
18    pub priority: Priority,
19    #[serde(default)]
20    pub created_at: Option<String>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
24#[serde(rename_all = "lowercase")]
25pub enum TodoStatus {
26    #[default]
27    Pending,
28    #[serde(alias = "in_progress")]
29    InProgress,
30    Done,
31    Blocked,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
35#[serde(rename_all = "lowercase")]
36pub enum Priority {
37    Low,
38    #[default]
39    Medium,
40    High,
41    Critical,
42}
43
44pub struct TodoReadTool {
45    root: PathBuf,
46}
47
48pub struct TodoWriteTool {
49    root: PathBuf,
50}
51
52impl Default for TodoReadTool {
53    fn default() -> Self {
54        Self::new()
55    }
56}
57
58impl Default for TodoWriteTool {
59    fn default() -> Self {
60        Self::new()
61    }
62}
63
64impl TodoReadTool {
65    pub fn new() -> Self {
66        Self {
67            root: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
68        }
69    }
70
71    fn load_todos(&self) -> Result<Vec<TodoItem>> {
72        let path = self.root.join(TODO_FILE);
73        if !path.exists() {
74            return Ok(Vec::new());
75        }
76        let content = std::fs::read_to_string(&path)?;
77        Ok(serde_json::from_str(&content)?)
78    }
79}
80
81impl TodoWriteTool {
82    pub fn new() -> Self {
83        Self {
84            root: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
85        }
86    }
87
88    fn load_todos(&self) -> Result<Vec<TodoItem>> {
89        let path = self.root.join(TODO_FILE);
90        if !path.exists() {
91            return Ok(Vec::new());
92        }
93        let content = std::fs::read_to_string(&path)?;
94        Ok(serde_json::from_str(&content)?)
95    }
96
97    fn save_todos(&self, todos: &[TodoItem]) -> Result<()> {
98        let path = self.root.join(TODO_FILE);
99        let content = serde_json::to_string_pretty(todos)?;
100        std::fs::write(&path, content)?;
101        Ok(())
102    }
103
104    fn generate_id(&self) -> String {
105        use std::time::{SystemTime, UNIX_EPOCH};
106        let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
107        format!("todo_{}", now.as_millis())
108    }
109}
110
111#[derive(Deserialize)]
112struct ReadParams {
113    #[serde(default)]
114    status: Option<String>,
115    #[serde(default)]
116    priority: Option<String>,
117}
118
119#[derive(Deserialize)]
120struct WriteParams {
121    action: String, // add, update, delete, clear
122    #[serde(default)]
123    id: Option<String>,
124    #[serde(default)]
125    content: Option<String>,
126    #[serde(default)]
127    status: Option<String>,
128    #[serde(default)]
129    priority: Option<String>,
130}
131
132#[async_trait]
133impl Tool for TodoReadTool {
134    fn id(&self) -> &str {
135        "todoread"
136    }
137    fn name(&self) -> &str {
138        "Todo Read"
139    }
140    fn description(&self) -> &str {
141        "Read todo items. Filter by status (pending/in_progress/done/blocked) or priority (low/medium/high/critical)."
142    }
143    fn parameters(&self) -> Value {
144        json!({
145            "type": "object",
146            "properties": {
147                "status": {"type": "string", "enum": ["pending", "in_progress", "done", "blocked"]},
148                "priority": {"type": "string", "enum": ["low", "medium", "high", "critical"]}
149            }
150        })
151    }
152
153    async fn execute(&self, params: Value) -> Result<ToolResult> {
154        let p: ReadParams = serde_json::from_value(params).unwrap_or(ReadParams {
155            status: None,
156            priority: None,
157        });
158
159        let todos = self.load_todos()?;
160
161        let filtered: Vec<&TodoItem> = todos
162            .iter()
163            .filter(|t| {
164                if let Some(ref status) = p.status {
165                    let expected = match status.as_str() {
166                        "pending" => TodoStatus::Pending,
167                        "in_progress" => TodoStatus::InProgress,
168                        "done" => TodoStatus::Done,
169                        "blocked" => TodoStatus::Blocked,
170                        _ => return true,
171                    };
172                    if t.status != expected {
173                        return false;
174                    }
175                }
176                if let Some(ref priority) = p.priority {
177                    let expected = match priority.as_str() {
178                        "low" => Priority::Low,
179                        "medium" => Priority::Medium,
180                        "high" => Priority::High,
181                        "critical" => Priority::Critical,
182                        _ => return true,
183                    };
184                    if t.priority != expected {
185                        return false;
186                    }
187                }
188                true
189            })
190            .collect();
191
192        if filtered.is_empty() {
193            return Ok(ToolResult::success("No todos found".to_string()));
194        }
195
196        let output = filtered
197            .iter()
198            .map(|t| {
199                let status_icon = match t.status {
200                    TodoStatus::Pending => "○",
201                    TodoStatus::InProgress => "◐",
202                    TodoStatus::Done => "●",
203                    TodoStatus::Blocked => "✗",
204                };
205                let priority_label = match t.priority {
206                    Priority::Low => "[low]",
207                    Priority::Medium => "",
208                    Priority::High => "[HIGH]",
209                    Priority::Critical => "[CRITICAL]",
210                };
211                format!("{} {} {} {}", status_icon, t.id, priority_label, t.content)
212            })
213            .collect::<Vec<_>>()
214            .join("\n");
215
216        Ok(ToolResult::success(output).with_metadata("count", json!(filtered.len())))
217    }
218}
219
220#[async_trait]
221impl Tool for TodoWriteTool {
222    fn id(&self) -> &str {
223        "todowrite"
224    }
225    fn name(&self) -> &str {
226        "Todo Write"
227    }
228    fn description(&self) -> &str {
229        "Manage todo items: add, update, delete, or clear todos."
230    }
231    fn parameters(&self) -> Value {
232        json!({
233            "type": "object",
234            "properties": {
235                "action": {"type": "string", "enum": ["add", "update", "delete", "clear"], "description": "Action to perform"},
236                "id": {"type": "string", "description": "Todo ID (for update/delete)"},
237                "content": {"type": "string", "description": "Todo content (for add/update)"},
238                "status": {"type": "string", "enum": ["pending", "in_progress", "done", "blocked"]},
239                "priority": {"type": "string", "enum": ["low", "medium", "high", "critical"]}
240            },
241            "required": ["action"]
242        })
243    }
244
245    async fn execute(&self, params: Value) -> Result<ToolResult> {
246        let p: WriteParams = serde_json::from_value(params).context("Invalid params")?;
247        let mut todos = self.load_todos()?;
248
249        match p.action.as_str() {
250            "add" => {
251                let content = p
252                    .content
253                    .ok_or_else(|| anyhow::anyhow!("content required for add"))?;
254                let status = p
255                    .status
256                    .map(|s| match s.as_str() {
257                        "in_progress" => TodoStatus::InProgress,
258                        "done" => TodoStatus::Done,
259                        "blocked" => TodoStatus::Blocked,
260                        _ => TodoStatus::Pending,
261                    })
262                    .unwrap_or_default();
263                let priority = p
264                    .priority
265                    .map(|s| match s.as_str() {
266                        "low" => Priority::Low,
267                        "high" => Priority::High,
268                        "critical" => Priority::Critical,
269                        _ => Priority::Medium,
270                    })
271                    .unwrap_or_default();
272
273                let id = self.generate_id();
274                todos.push(TodoItem {
275                    id: id.clone(),
276                    content,
277                    status,
278                    priority,
279                    created_at: Some(chrono::Utc::now().to_rfc3339()),
280                });
281                self.save_todos(&todos)?;
282                Ok(ToolResult::success(format!("Added todo: {}", id)))
283            }
284            "update" => {
285                let id =
286                    p.id.ok_or_else(|| anyhow::anyhow!("id required for update"))?;
287                let todo = todos
288                    .iter_mut()
289                    .find(|t| t.id == id)
290                    .ok_or_else(|| anyhow::anyhow!("Todo not found: {}", id))?;
291
292                if let Some(content) = p.content {
293                    todo.content = content;
294                }
295                if let Some(status) = p.status {
296                    todo.status = match status.as_str() {
297                        "in_progress" => TodoStatus::InProgress,
298                        "done" => TodoStatus::Done,
299                        "blocked" => TodoStatus::Blocked,
300                        _ => TodoStatus::Pending,
301                    };
302                }
303                if let Some(priority) = p.priority {
304                    todo.priority = match priority.as_str() {
305                        "low" => Priority::Low,
306                        "high" => Priority::High,
307                        "critical" => Priority::Critical,
308                        _ => Priority::Medium,
309                    };
310                }
311                self.save_todos(&todos)?;
312                Ok(ToolResult::success(format!("Updated todo: {}", id)))
313            }
314            "delete" => {
315                let id =
316                    p.id.ok_or_else(|| anyhow::anyhow!("id required for delete"))?;
317                let len_before = todos.len();
318                todos.retain(|t| t.id != id);
319                if todos.len() == len_before {
320                    return Ok(ToolResult::error(format!("Todo not found: {}", id)));
321                }
322                self.save_todos(&todos)?;
323                Ok(ToolResult::success(format!("Deleted todo: {}", id)))
324            }
325            "clear" => {
326                let count = todos.len();
327                todos.clear();
328                self.save_todos(&todos)?;
329                Ok(ToolResult::success(format!("Cleared {} todos", count)))
330            }
331            _ => Ok(ToolResult::error(format!("Unknown action: {}", p.action))),
332        }
333    }
334}