Skip to main content

aacode_rs/tools/
todo.rs

1// Copyright (c) 2026 xiefujin <490021684@qq.com>
2// Licensed under GPL-3.0, see LICENSE file for full license terms.
3
4//! Todo tools — markdown-based todo lists under `.aacode/todos/`.
5//!
6//! Ported from Python `tools/todo_tools.py` + `utils/todo_manager.py`.
7//! Each session gets its own todo file (`todo_<session>.md`); a shared
8//! fallback file (`todo.md`) is used when no session is active.
9
10use super::registry::Tool;
11use super::schema::{ParamType, ToolParameter, ToolSchema};
12use crate::error::Result;
13use serde_json::{json, Value};
14use std::path::PathBuf;
15use std::sync::atomic::AtomicBool;
16use std::sync::Arc;
17
18/// Shared todo store. Uses `get_session_id` to pick the right file.
19pub struct TodoStore {
20    dir: PathBuf,
21    next_id: std::sync::atomic::AtomicUsize,
22    /// Returns the current session id, or None if no session active.
23    get_session_id: Arc<dyn Fn() -> Option<String> + Send + Sync>,
24    /// Serializes read-modify-write cycles. Parallel tool calls (the model can
25    /// issue several `add_todo_item` in one turn) otherwise clobber each other's
26    /// writes and silently lose items.
27    lock: std::sync::Mutex<()>,
28}
29
30impl TodoStore {
31    pub fn new(project_path: &std::path::Path) -> Self {
32        Self::with_session_resolver(project_path, Arc::new(|| None))
33    }
34
35    pub fn with_session_resolver(
36        project_path: &std::path::Path,
37        resolver: Arc<dyn Fn() -> Option<String> + Send + Sync>,
38    ) -> Self {
39        let dir = project_path.join(".aacode").join("todos");
40        let _ = std::fs::create_dir_all(&dir);
41        // Ensure a default file exists.
42        let default = dir.join("todo.md");
43        if !default.exists() {
44            let _ = std::fs::write(&default, "# Todo List\n\n");
45        }
46        // Find max id across all todo files.
47        let mut max = 0;
48        if let Ok(rd) = std::fs::read_dir(&dir) {
49            for e in rd.flatten() {
50                let p = e.path();
51                if p.extension().map(|x| x == "md").unwrap_or(false) {
52                    if let Ok(c) = std::fs::read_to_string(&p) {
53                        let m = max_existing_id(&c);
54                        max = max.max(m);
55                    }
56                }
57            }
58        }
59        TodoStore {
60            dir,
61            next_id: std::sync::atomic::AtomicUsize::new(max + 1),
62            get_session_id: resolver,
63            lock: std::sync::Mutex::new(()),
64        }
65    }
66
67    /// Resolve the todo file path: prefer session-specific, fallback to default.
68    fn file_path(&self) -> PathBuf {
69        let sid = (self.get_session_id)();
70        if let Some(id) = sid {
71            let p = self.dir.join(format!("todo_{id}.md"));
72            if !p.exists() {
73                // Create a fresh file for the new session.
74                let _ = std::fs::write(&p, "# Todo List\n\n");
75            }
76            p
77        } else {
78            self.dir.join("todo.md")
79        }
80    }
81
82    fn read(&self) -> String {
83        std::fs::read_to_string(self.file_path()).unwrap_or_default()
84    }
85
86    fn write(&self, content: &str) {
87        let _ = std::fs::write(self.file_path(), content);
88    }
89
90    fn add(&self, description: &str, priority: &str, category: &str) -> String {
91        let _g = self.lock.lock().unwrap_or_else(|e| e.into_inner());
92        let id = format!(
93            "t{}",
94            self.next_id
95                .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
96        );
97        let mut content = self.read();
98        content.push_str(&format!(
99            "- [ ] [#{id}] ({priority}/{category}): {description}\n"
100        ));
101        self.write(&content);
102        id
103    }
104
105    fn mark_done(&self, todo_id: Option<&str>, pattern: Option<&str>) -> bool {
106        let _g = self.lock.lock().unwrap_or_else(|e| e.into_inner());
107        let content = self.read();
108        let (new_content, changed) = mark_in_content(&content, todo_id, pattern);
109        if changed {
110            self.write(&new_content);
111        }
112        changed
113    }
114
115    fn update(&self, old_pattern: &str, new_desc: &str) -> bool {
116        let _g = self.lock.lock().unwrap_or_else(|e| e.into_inner());
117        let content = self.read();
118        let mut out = String::new();
119        let mut changed = false;
120        for line in content.lines() {
121            if !changed
122                && line.trim_start().starts_with("- [")
123                && line.to_lowercase().contains(&old_pattern.to_lowercase())
124            {
125                if let Some(pos) = line.rfind(": ") {
126                    out.push_str(&line[..pos + 2]);
127                    out.push_str(new_desc);
128                    out.push('\n');
129                    changed = true;
130                    continue;
131                }
132            }
133            out.push_str(line);
134            out.push('\n');
135        }
136        if changed {
137            self.write(&out);
138        }
139        changed
140    }
141
142    fn summary(&self) -> (usize, usize, usize) {
143        let content = self.read();
144        let mut total = 0;
145        let mut done = 0;
146        for line in content.lines() {
147            let t = line.trim_start();
148            if t.starts_with("- [ ]") {
149                total += 1;
150            } else if t.starts_with("- [x]") {
151                total += 1;
152                done += 1;
153            }
154        }
155        (total, done, total - done)
156    }
157
158    /// List pending todo items for hint display (when mark fails).
159    fn pending_hint(&self) -> String {
160        let content = self.read();
161        let mut pending = Vec::new();
162        for line in content.lines() {
163            let t = line.trim_start();
164            if t.starts_with("- [ ]") {
165                let re = regex::Regex::new(r"\[#(\w+)\]\s*:\s*(.*)").unwrap();
166                if let Some(caps) = re.captures(t) {
167                    pending.push((caps[1].to_string(), caps[2].to_string()));
168                } else {
169                    let desc = t.replacen("- [ ]", "", 1).trim().to_string();
170                    let short: String = desc.chars().take(80).collect();
171                    pending.push((String::new(), short));
172                }
173            }
174        }
175        if pending.is_empty() {
176            return String::new();
177        }
178        let mut hint = String::from("📋 Available pending todos:\n");
179        for (tid, desc) in pending.iter().take(15) {
180            if tid.is_empty() {
181                hint.push_str(&format!("  - {desc}\n"));
182            } else {
183                hint.push_str(&format!("  #{tid}: {desc}\n"));
184            }
185        }
186        if pending.len() > 15 {
187            hint.push_str(&format!("  ... and {} more\n", pending.len() - 15));
188        }
189        hint.trim().to_string()
190    }
191
192    /// List all todo files in the directory.
193    pub fn list_files(&self) -> Vec<String> {
194        let mut files = Vec::new();
195        if let Ok(rd) = std::fs::read_dir(&self.dir) {
196            for e in rd.flatten() {
197                let name = e.file_name().to_string_lossy().to_string();
198                if name.ends_with(".md") {
199                    files.push(name);
200                }
201            }
202        }
203        files.sort();
204        files
205    }
206}
207
208fn max_existing_id(content: &str) -> usize {
209    let mut max = 0;
210    for line in content.lines() {
211        if let Some(start) = line.find("[#t") {
212            let rest = &line[start + 3..];
213            let num: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
214            if let Ok(n) = num.parse::<usize>() {
215                max = max.max(n);
216            }
217        }
218    }
219    max
220}
221
222fn mark_in_content(content: &str, todo_id: Option<&str>, pattern: Option<&str>) -> (String, bool) {
223    let mut out = String::new();
224    let mut changed = false;
225    for line in content.lines() {
226        let mut newline = line.to_string();
227        if !changed && line.trim_start().starts_with("- [ ]") {
228            let matches = match (todo_id, pattern) {
229                (Some(id), _) => line.contains(&format!("[#{id}]")),
230                (None, Some(p)) => line.to_lowercase().contains(&p.to_lowercase()),
231                _ => false,
232            };
233            if matches {
234                newline = line.replacen("- [ ]", "- [x]", 1);
235                changed = true;
236            }
237        }
238        out.push_str(&newline);
239        out.push('\n');
240    }
241    (out, changed)
242}
243
244// ── Tools ──
245
246pub struct AddTodoTool {
247    pub store: Arc<TodoStore>,
248}
249#[async_trait::async_trait]
250impl Tool for AddTodoTool {
251    fn schema(&self) -> ToolSchema {
252        ToolSchema::new(
253            "add_todo_item",
254            "Add a todo item (for multi-step work you want to track across turns). Returns a todo_id (e.g. t1) for later mark_todo_completed(todo_id=...). Don't add a todo just to complete it immediately in the same step.",
255            vec![
256                ToolParameter::new("description", ParamType::String, true, "Todo description", &["item", "task", "todo", "title"]),
257                ToolParameter::new("priority", ParamType::String, false, "low|medium|high", &[]),
258                ToolParameter::new("category", ParamType::String, false, "Category label", &[]),
259            ],
260        )
261    }
262    async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
263        let desc = args
264            .get("description")
265            .and_then(|v| v.as_str())
266            .unwrap_or("");
267        if desc.is_empty() {
268            return Ok(json!({"success": false, "error": "missing description"}).to_string());
269        }
270        let priority = args
271            .get("priority")
272            .and_then(|v| v.as_str())
273            .unwrap_or("medium");
274        let category = args
275            .get("category")
276            .and_then(|v| v.as_str())
277            .unwrap_or("Task");
278        let store = self.store.clone();
279        let desc_owned = desc.to_string();
280        let priority_owned = priority.to_string();
281        let category_owned = category.to_string();
282        let id = tokio::task::spawn_blocking(move || {
283            store.add(&desc_owned, &priority_owned, &category_owned)
284        })
285        .await
286        .unwrap();
287        Ok(json!({
288            "success": true,
289            "todo_id": id,
290            "message": format!("Added [#{id}]: {desc} (use mark_todo_completed(todo_id=\"{id}\") when done)"),
291        })
292        .to_string())
293    }
294}
295
296pub struct MarkTodoTool {
297    pub store: Arc<TodoStore>,
298}
299#[async_trait::async_trait]
300impl Tool for MarkTodoTool {
301    fn schema(&self) -> ToolSchema {
302        ToolSchema::new(
303            "mark_todo_completed",
304            "Mark a todo item complete. Prefer todo_id (from add_todo_item); item_pattern is a fallback.",
305            vec![
306                ToolParameter::new("todo_id", ParamType::String, false, "Todo id e.g. t1", &["id"]),
307                ToolParameter::new("item_pattern", ParamType::String, false, "Fuzzy match text", &["title", "item", "task", "description", "text", "pattern", "todo"]),
308            ],
309        )
310    }
311    async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
312        let id = args.get("todo_id").and_then(|v| v.as_str());
313        let pat = args.get("item_pattern").and_then(|v| v.as_str());
314        if id.is_none() && pat.is_none() {
315            let store = self.store.clone();
316            let hint = tokio::task::spawn_blocking(move || store.pending_hint())
317                .await
318                .unwrap();
319            let err = if hint.is_empty() {
320                "provide todo_id or item_pattern".to_string()
321            } else {
322                format!("provide todo_id or item_pattern\n\n{hint}")
323            };
324            return Ok(
325                json!({"success": false, "error": err, "todo_id": id, "item_pattern": pat})
326                    .to_string(),
327            );
328        }
329        let store = self.store.clone();
330        let id_owned = id.map(|s| s.to_string());
331        let pat_owned = pat.map(|s| s.to_string());
332        let ok = tokio::task::spawn_blocking(move || {
333            store.mark_done(id_owned.as_deref(), pat_owned.as_deref())
334        })
335        .await
336        .unwrap();
337        Ok(json!({"success": ok, "todo_id": id, "item_pattern": pat}).to_string())
338    }
339}
340
341pub struct UpdateTodoTool {
342    pub store: Arc<TodoStore>,
343}
344#[async_trait::async_trait]
345impl Tool for UpdateTodoTool {
346    fn schema(&self) -> ToolSchema {
347        ToolSchema::new(
348            "update_todo_item",
349            "Update a todo item's description.",
350            vec![
351                ToolParameter::new("old_pattern", ParamType::String, true, "Match text", &[]),
352                ToolParameter::new("new_item", ParamType::String, true, "New description", &[]),
353            ],
354        )
355    }
356    async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
357        let old = args
358            .get("old_pattern")
359            .and_then(|v| v.as_str())
360            .unwrap_or("");
361        let new = args.get("new_item").and_then(|v| v.as_str()).unwrap_or("");
362        let store = self.store.clone();
363        let old = old.to_string();
364        let new = new.to_string();
365        let ok = tokio::task::spawn_blocking(move || store.update(&old, &new))
366            .await
367            .unwrap();
368        Ok(json!({"success": ok}).to_string())
369    }
370}
371
372pub struct TodoSummaryTool {
373    pub store: Arc<TodoStore>,
374}
375#[async_trait::async_trait]
376impl Tool for TodoSummaryTool {
377    fn schema(&self) -> ToolSchema {
378        ToolSchema::new(
379            "get_todo_summary",
380            "Get a summary of the todo list.",
381            vec![],
382        )
383    }
384    async fn call(&self, _args: &Value, _c: &AtomicBool) -> Result<String> {
385        let store = self.store.clone();
386        let (total, done, pending) = tokio::task::spawn_blocking(move || store.summary())
387            .await
388            .unwrap();
389        let rate = if total > 0 {
390            (done as f64 / total as f64) * 100.0
391        } else {
392            0.0
393        };
394        Ok(json!({
395            "success": true,
396            "total_todos": total,
397            "completed_todos": done,
398            "pending_todos": pending,
399            "completion_rate": rate,
400        })
401        .to_string())
402    }
403}
404
405pub struct ListTodoFilesTool {
406    pub store: Arc<TodoStore>,
407}
408#[async_trait::async_trait]
409impl Tool for ListTodoFilesTool {
410    fn schema(&self) -> ToolSchema {
411        ToolSchema::new("list_todo_files", "List all todo list files.", vec![])
412    }
413    async fn call(&self, _args: &Value, _c: &AtomicBool) -> Result<String> {
414        let store = self.store.clone();
415        let files = tokio::task::spawn_blocking(move || store.list_files())
416            .await
417            .unwrap();
418        Ok(json!({"success": true, "files": files, "count": files.len()}).to_string())
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use std::sync::Mutex;
426
427    fn store() -> (Arc<TodoStore>, PathBuf) {
428        let d = std::env::temp_dir().join(format!(
429            "aacode_todo_{}_{}",
430            std::process::id(),
431            uuid::Uuid::new_v4().simple()
432        ));
433        std::fs::create_dir_all(&d).unwrap();
434        (Arc::new(TodoStore::new(&d)), d)
435    }
436
437    #[tokio::test]
438    async fn add_returns_id_and_marks_done() {
439        let (s, _) = store();
440        let cancel = AtomicBool::new(false);
441        let add = AddTodoTool { store: s.clone() };
442        let out = add
443            .call(&json!({"description": "write tests"}), &cancel)
444            .await
445            .unwrap();
446        let v: Value = serde_json::from_str(&out).unwrap();
447        let id = v["todo_id"].as_str().unwrap().to_string();
448        assert_eq!(id, "t1");
449
450        let mark = MarkTodoTool { store: s.clone() };
451        let out = mark.call(&json!({"todo_id": id}), &cancel).await.unwrap();
452        assert_eq!(
453            serde_json::from_str::<Value>(&out).unwrap()["success"],
454            true
455        );
456
457        let sum = TodoSummaryTool { store: s.clone() };
458        let out = sum.call(&json!({}), &cancel).await.unwrap();
459        let v: Value = serde_json::from_str(&out).unwrap();
460        assert_eq!(v["total_todos"], 1);
461        assert_eq!(v["completed_todos"], 1);
462    }
463
464    #[test]
465    fn concurrent_adds_do_not_lose_items() {
466        let (s, _) = store();
467        let mut handles = Vec::new();
468        for i in 0..12 {
469            let s = s.clone();
470            handles.push(std::thread::spawn(move || {
471                s.add(&format!("item {i}"), "medium", "test")
472            }));
473        }
474        for h in handles {
475            h.join().unwrap();
476        }
477        let content = std::fs::read_to_string(s.file_path()).unwrap();
478        let n = content
479            .lines()
480            .filter(|l| l.trim_start().starts_with("- [ ]"))
481            .count();
482        assert_eq!(n, 12, "all concurrent adds must persist:\n{content}");
483    }
484
485    #[tokio::test]
486    async fn mark_by_pattern() {
487        let (s, _) = store();
488        let cancel = AtomicBool::new(false);
489        AddTodoTool { store: s.clone() }
490            .call(&json!({"description": "implement login"}), &cancel)
491            .await
492            .unwrap();
493        assert!(s.mark_done(None, Some("login")));
494    }
495
496    #[tokio::test]
497    async fn missing_id_gives_hint() {
498        let (s, _) = store();
499        let cancel = AtomicBool::new(false);
500        AddTodoTool { store: s.clone() }
501            .call(&json!({"description": "test"}), &cancel)
502            .await
503            .unwrap();
504        let mark = MarkTodoTool { store: s.clone() };
505        let out = mark.call(&json!({}), &cancel).await.unwrap();
506        let v: Value = serde_json::from_str(&out).unwrap();
507        assert_eq!(v["success"], false);
508        // Should list pending todos.
509        assert!(v["error"].as_str().unwrap().contains("Available"));
510    }
511
512    #[tokio::test]
513    async fn session_isolation() {
514        let d = std::env::temp_dir().join(format!(
515            "aacode_sess_todo_{}",
516            uuid::Uuid::new_v4().simple()
517        ));
518        std::fs::create_dir_all(&d).unwrap();
519
520        // Simulate session switching via resolver.
521        let session_id: Arc<Mutex<Option<String>>> =
522            Arc::new(Mutex::new(Some("sess_a".to_string())));
523        let rid = session_id.clone();
524        let store = Arc::new(TodoStore::with_session_resolver(
525            &d,
526            Arc::new(move || rid.lock().unwrap().clone()),
527        ));
528
529        let cancel = AtomicBool::new(false);
530        AddTodoTool {
531            store: store.clone(),
532        }
533        .call(&json!({"description": "task a"}), &cancel)
534        .await
535        .unwrap();
536
537        // Switch session.
538        *session_id.lock().unwrap() = Some("sess_b".to_string());
539
540        let out = AddTodoTool {
541            store: store.clone(),
542        }
543        .call(&json!({"description": "task b"}), &cancel)
544        .await
545        .unwrap();
546        let v: Value = serde_json::from_str(&out).unwrap();
547        assert_eq!(v["todo_id"], "t2"); // next_id is global (cross-session)
548
549        // Back to sess_a, should see the t1 from earlier.
550        *session_id.lock().unwrap() = Some("sess_a".to_string());
551        let sum = TodoSummaryTool {
552            store: store.clone(),
553        };
554        let out = sum.call(&json!({}), &cancel).await.unwrap();
555        let v: Value = serde_json::from_str(&out).unwrap();
556        assert_eq!(v["total_todos"], 1);
557    }
558
559    #[tokio::test]
560    async fn update_item() {
561        let (s, _) = store();
562        let cancel = AtomicBool::new(false);
563        AddTodoTool { store: s.clone() }
564            .call(&json!({"description": "old desc"}), &cancel)
565            .await
566            .unwrap();
567        let upd = UpdateTodoTool { store: s.clone() };
568        assert!(serde_json::from_str::<Value>(
569            &upd.call(
570                &json!({"old_pattern": "old desc", "new_item": "new desc"}),
571                &cancel
572            )
573            .await
574            .unwrap()
575        )
576        .unwrap()["success"]
577            .as_bool()
578            .unwrap());
579        assert!(s.read().contains("new desc"));
580    }
581}