1use std::ffi::OsString;
20use std::fmt::Write;
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23use std::sync::atomic::{AtomicU64, Ordering};
24
25use crate::{PrimitiveToolName, Tool, ToolContext, ToolResult, ToolTier};
26use anyhow::{Context, Result};
27use serde::{Deserialize, Serialize};
28use serde_json::{Value, json};
29use tokio::sync::RwLock;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum TodoStatus {
35 Pending,
37 InProgress,
39 Completed,
41}
42
43impl TodoStatus {
44 #[must_use]
46 pub const fn icon(&self) -> &'static str {
47 match self {
48 Self::Pending => "○",
49 Self::InProgress => "⚡",
50 Self::Completed => "✓",
51 }
52 }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct TodoItem {
58 pub content: String,
60 pub status: TodoStatus,
62 pub active_form: String,
64}
65
66impl TodoItem {
67 #[must_use]
69 pub fn new(content: impl Into<String>, active_form: impl Into<String>) -> Self {
70 Self {
71 content: content.into(),
72 status: TodoStatus::Pending,
73 active_form: active_form.into(),
74 }
75 }
76
77 #[must_use]
79 pub fn with_status(
80 content: impl Into<String>,
81 active_form: impl Into<String>,
82 status: TodoStatus,
83 ) -> Self {
84 Self {
85 content: content.into(),
86 status,
87 active_form: active_form.into(),
88 }
89 }
90
91 #[must_use]
93 pub const fn icon(&self) -> &'static str {
94 self.status.icon()
95 }
96}
97
98#[derive(Debug, Default)]
100pub struct TodoState {
101 pub items: Vec<TodoItem>,
103 storage_path: Option<PathBuf>,
105}
106
107impl TodoState {
108 #[must_use]
110 pub const fn new() -> Self {
111 Self {
112 items: Vec::new(),
113 storage_path: None,
114 }
115 }
116
117 #[must_use]
119 pub const fn with_storage(path: PathBuf) -> Self {
120 Self {
121 items: Vec::new(),
122 storage_path: Some(path),
123 }
124 }
125
126 pub fn set_storage_path(&mut self, path: PathBuf) {
128 self.storage_path = Some(path);
129 }
130
131 pub async fn load(&mut self) -> Result<()> {
137 if let Some(ref path) = self.storage_path.as_ref().filter(|p| p.exists()) {
138 let content = tokio::fs::read_to_string(path)
139 .await
140 .context("Failed to read todos file")?;
141 self.items = serde_json::from_str(&content).context("Failed to parse todos file")?;
142 }
143 Ok(())
144 }
145
146 pub async fn save(&self) -> Result<()> {
158 let Some(path) = self.storage_path.as_ref() else {
159 return Ok(());
160 };
161
162 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
164 tokio::fs::create_dir_all(parent)
165 .await
166 .context("Failed to create todos directory")?;
167 }
168
169 let content =
170 serde_json::to_string_pretty(&self.items).context("Failed to serialize todos")?;
171
172 let tmp_path = temp_sibling_path(path)?;
173 tokio::fs::write(&tmp_path, content)
174 .await
175 .context("Failed to write temp todos file")?;
176
177 if let Err(e) = tokio::fs::rename(&tmp_path, path).await {
178 let _ = tokio::fs::remove_file(&tmp_path).await;
180 return Err(e).context("Failed to atomically replace todos file");
181 }
182
183 Ok(())
184 }
185
186 pub fn set_items(&mut self, items: Vec<TodoItem>) {
188 self.items = items;
189 }
190
191 pub fn add(&mut self, item: TodoItem) {
193 self.items.push(item);
194 }
195
196 #[must_use]
198 pub fn count_by_status(&self) -> (usize, usize, usize) {
199 let pending = self
200 .items
201 .iter()
202 .filter(|i| i.status == TodoStatus::Pending)
203 .count();
204 let in_progress = self
205 .items
206 .iter()
207 .filter(|i| i.status == TodoStatus::InProgress)
208 .count();
209 let completed = self
210 .items
211 .iter()
212 .filter(|i| i.status == TodoStatus::Completed)
213 .count();
214 (pending, in_progress, completed)
215 }
216
217 #[must_use]
219 pub fn current_task(&self) -> Option<&TodoItem> {
220 self.items
221 .iter()
222 .find(|i| i.status == TodoStatus::InProgress)
223 }
224
225 #[must_use]
227 pub fn format_display(&self) -> String {
228 if self.items.is_empty() {
229 return "No tasks".to_string();
230 }
231
232 let (_pending, in_progress, completed) = self.count_by_status();
233 let total = self.items.len();
234
235 let mut output = format!("TODO ({completed}/{total})");
236
237 if in_progress > 0
238 && let Some(current) = self.current_task()
239 {
240 let _ = write!(output, " - {}", current.active_form);
241 }
242
243 output.push('\n');
244
245 for item in &self.items {
246 let _ = writeln!(output, " {} {}", item.icon(), item.content);
247 }
248
249 output
250 }
251
252 #[must_use]
254 pub const fn is_empty(&self) -> bool {
255 self.items.is_empty()
256 }
257
258 #[must_use]
260 pub const fn len(&self) -> usize {
261 self.items.len()
262 }
263}
264
265fn temp_sibling_path(target: &Path) -> Result<PathBuf> {
270 static COUNTER: AtomicU64 = AtomicU64::new(0);
271
272 let file_name = target
273 .file_name()
274 .context("storage path has no file name")?;
275 let nonce = COUNTER.fetch_add(1, Ordering::Relaxed);
276
277 let mut tmp_name = OsString::from(".");
278 tmp_name.push(file_name);
279 tmp_name.push(format!(".tmp.{}.{nonce}", std::process::id()));
280
281 Ok(
282 match target.parent().filter(|p| !p.as_os_str().is_empty()) {
283 Some(parent) => parent.join(tmp_name),
284 None => PathBuf::from(tmp_name),
285 },
286 )
287}
288
289pub struct TodoWriteTool {
291 state: Arc<RwLock<TodoState>>,
293}
294
295impl TodoWriteTool {
296 #[must_use]
298 pub const fn new(state: Arc<RwLock<TodoState>>) -> Self {
299 Self { state }
300 }
301}
302
303#[derive(Debug, Deserialize)]
305struct TodoItemInput {
306 content: String,
307 status: TodoStatus,
308 #[serde(rename = "activeForm")]
309 active_form: String,
310}
311
312#[derive(Debug, Deserialize)]
314struct TodoWriteInput {
315 todos: Vec<TodoItemInput>,
316}
317
318impl<Ctx: Send + Sync + 'static> Tool<Ctx> for TodoWriteTool {
319 type Name = PrimitiveToolName;
320
321 fn name(&self) -> PrimitiveToolName {
322 PrimitiveToolName::TodoWrite
323 }
324
325 fn display_name(&self) -> &'static str {
326 "Update Tasks"
327 }
328
329 fn description(&self) -> &'static str {
330 "Update the TODO list to track tasks and show progress to the user. \
331 Use this tool frequently to plan complex tasks and mark progress. \
332 Each item needs 'content' (imperative form like 'Fix the bug'), \
333 'status' (pending/in_progress/completed), and 'activeForm' \
334 (present continuous like 'Fixing the bug'). \
335 Mark tasks completed immediately when done - don't batch completions."
336 }
337
338 fn input_schema(&self) -> Value {
339 json!({
340 "type": "object",
341 "required": ["todos"],
342 "properties": {
343 "todos": {
344 "type": "array",
345 "description": "The complete TODO list (replaces existing)",
346 "items": {
347 "type": "object",
348 "required": ["content", "status", "activeForm"],
349 "properties": {
350 "content": {
351 "type": "string",
352 "description": "Task description in imperative form (e.g., 'Fix the bug')"
353 },
354 "status": {
355 "type": "string",
356 "enum": ["pending", "in_progress", "completed"],
357 "description": "Current status of the task"
358 },
359 "activeForm": {
360 "type": "string",
361 "description": "Present continuous form shown during execution (e.g., 'Fixing the bug')"
362 }
363 }
364 }
365 }
366 }
367 })
368 }
369
370 fn tier(&self) -> ToolTier {
371 ToolTier::Observe }
373
374 async fn execute(&self, _ctx: &ToolContext<Ctx>, input: Value) -> Result<ToolResult> {
375 let input: TodoWriteInput =
376 serde_json::from_value(input).context("Invalid input for todo_write")?;
377
378 let items: Vec<TodoItem> = input
379 .todos
380 .into_iter()
381 .map(|t| TodoItem {
382 content: t.content,
383 status: t.status,
384 active_form: t.active_form,
385 })
386 .collect();
387
388 let mut state = self.state.write().await;
392 state.set_items(items);
393 let snapshot = TodoState {
394 items: state.items.clone(),
395 storage_path: state.storage_path.clone(),
396 };
397 let display = state.format_display();
398 drop(state);
399
400 if let Err(e) = snapshot.save().await {
401 log::warn!("Failed to persist todos: {e}");
402 return Ok(ToolResult::error(format!(
403 "TODO list updated in memory, but persisting to storage failed: {e}\n\n{display}"
404 )));
405 }
406
407 Ok(ToolResult::success(format!(
408 "TODO list updated.\n\n{display}"
409 )))
410 }
411}
412
413pub struct TodoReadTool {
415 state: Arc<RwLock<TodoState>>,
417}
418
419impl TodoReadTool {
420 #[must_use]
422 pub const fn new(state: Arc<RwLock<TodoState>>) -> Self {
423 Self { state }
424 }
425}
426
427impl<Ctx: Send + Sync + 'static> Tool<Ctx> for TodoReadTool {
428 type Name = PrimitiveToolName;
429
430 fn name(&self) -> PrimitiveToolName {
431 PrimitiveToolName::TodoRead
432 }
433
434 fn display_name(&self) -> &'static str {
435 "Read Tasks"
436 }
437
438 fn description(&self) -> &'static str {
439 "Read the current TODO list to see task status and progress."
440 }
441
442 fn input_schema(&self) -> Value {
443 json!({
444 "type": "object",
445 "properties": {}
446 })
447 }
448
449 fn tier(&self) -> ToolTier {
450 ToolTier::Observe
451 }
452
453 async fn execute(&self, _ctx: &ToolContext<Ctx>, _input: Value) -> Result<ToolResult> {
454 let display = {
455 let state = self.state.read().await;
456 state.format_display()
457 };
458
459 Ok(ToolResult::success(display))
460 }
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466
467 #[test]
468 fn test_todo_status_icons() {
469 assert_eq!(TodoStatus::Pending.icon(), "○");
470 assert_eq!(TodoStatus::InProgress.icon(), "⚡");
471 assert_eq!(TodoStatus::Completed.icon(), "✓");
472 }
473
474 #[test]
475 fn test_todo_item_new() {
476 let item = TodoItem::new("Fix the bug", "Fixing the bug");
477 assert_eq!(item.content, "Fix the bug");
478 assert_eq!(item.active_form, "Fixing the bug");
479 assert_eq!(item.status, TodoStatus::Pending);
480 }
481
482 #[test]
483 fn test_todo_state_count_by_status() {
484 let mut state = TodoState::new();
485 state.add(TodoItem::with_status(
486 "Task 1",
487 "Task 1",
488 TodoStatus::Pending,
489 ));
490 state.add(TodoItem::with_status(
491 "Task 2",
492 "Task 2",
493 TodoStatus::InProgress,
494 ));
495 state.add(TodoItem::with_status(
496 "Task 3",
497 "Task 3",
498 TodoStatus::Completed,
499 ));
500 state.add(TodoItem::with_status(
501 "Task 4",
502 "Task 4",
503 TodoStatus::Completed,
504 ));
505
506 let (pending, in_progress, completed) = state.count_by_status();
507 assert_eq!(pending, 1);
508 assert_eq!(in_progress, 1);
509 assert_eq!(completed, 2);
510 }
511
512 #[test]
513 fn test_todo_state_current_task() {
514 let mut state = TodoState::new();
515 state.add(TodoItem::with_status(
516 "Task 1",
517 "Task 1",
518 TodoStatus::Pending,
519 ));
520 assert!(state.current_task().is_none());
521
522 state.add(TodoItem::with_status(
523 "Task 2",
524 "Working on Task 2",
525 TodoStatus::InProgress,
526 ));
527 let current = state.current_task().unwrap();
528 assert_eq!(current.content, "Task 2");
529 assert_eq!(current.active_form, "Working on Task 2");
530 }
531
532 #[test]
533 fn test_todo_state_format_display() {
534 let mut state = TodoState::new();
535 assert_eq!(state.format_display(), "No tasks");
536
537 state.add(TodoItem::with_status(
538 "Fix bug",
539 "Fixing bug",
540 TodoStatus::InProgress,
541 ));
542 state.add(TodoItem::with_status(
543 "Write tests",
544 "Writing tests",
545 TodoStatus::Pending,
546 ));
547
548 let display = state.format_display();
549 assert!(display.contains("TODO (0/2)"));
550 assert!(display.contains("Fixing bug"));
551 assert!(display.contains("⚡ Fix bug"));
552 assert!(display.contains("○ Write tests"));
553 }
554
555 #[test]
556 fn test_todo_status_serde() {
557 let status = TodoStatus::InProgress;
558 let json = serde_json::to_string(&status).unwrap();
559 assert_eq!(json, "\"in_progress\"");
560
561 let parsed: TodoStatus = serde_json::from_str("\"completed\"").unwrap();
562 assert_eq!(parsed, TodoStatus::Completed);
563 }
564
565 #[tokio::test]
566 async fn save_then_load_round_trips_through_storage() -> Result<()> {
567 let dir = tempfile::tempdir().context("create temp dir")?;
568 let path = dir.path().join("todos.json");
569
570 let mut state = TodoState::with_storage(path.clone());
571 state.add(TodoItem::with_status(
572 "Fix bug",
573 "Fixing bug",
574 TodoStatus::InProgress,
575 ));
576 state.add(TodoItem::with_status(
577 "Write tests",
578 "Writing tests",
579 TodoStatus::Pending,
580 ));
581 state.save().await?;
582
583 assert!(path.exists(), "target file should exist after save");
584
585 let mut entries = tokio::fs::read_dir(dir.path()).await?;
587 while let Some(entry) = entries.next_entry().await? {
588 let name = entry.file_name();
589 let name = name.to_string_lossy();
590 assert!(!name.contains(".tmp."), "temp file leaked: {name}");
591 }
592
593 let mut loaded = TodoState::with_storage(path);
594 loaded.load().await?;
595 assert_eq!(loaded.len(), 2);
596 assert_eq!(loaded.items[0].content, "Fix bug");
597 assert_eq!(loaded.items[1].status, TodoStatus::Pending);
598
599 Ok(())
600 }
601
602 #[tokio::test]
603 async fn save_overwrites_existing_file_atomically() -> Result<()> {
604 let dir = tempfile::tempdir().context("create temp dir")?;
605 let path = dir.path().join("todos.json");
606
607 let mut first = TodoState::with_storage(path.clone());
608 first.add(TodoItem::new("Old task", "Doing old task"));
609 first.save().await?;
610
611 let mut second = TodoState::with_storage(path.clone());
612 second.add(TodoItem::new("New task", "Doing new task"));
613 second.save().await?;
614
615 let mut loaded = TodoState::with_storage(path);
616 loaded.load().await?;
617 assert_eq!(loaded.len(), 1);
618 assert_eq!(loaded.items[0].content, "New task");
619
620 Ok(())
621 }
622
623 #[tokio::test]
624 async fn save_is_noop_without_storage_path() -> Result<()> {
625 let mut state = TodoState::new();
626 state.add(TodoItem::new("Task", "Doing task"));
627 state.save().await?;
628 Ok(())
629 }
630}