1use 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
18pub struct TodoStore {
20 dir: PathBuf,
21 next_id: std::sync::atomic::AtomicUsize,
22 get_session_id: Arc<dyn Fn() -> Option<String> + Send + Sync>,
24 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 let default = dir.join("todo.md");
43 if !default.exists() {
44 let _ = std::fs::write(&default, "# Todo List\n\n");
45 }
46 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 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 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 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 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
244pub 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 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 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 *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"); *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}