apollo/tools/
todo_write.rs1use std::path::PathBuf;
4
5use async_trait::async_trait;
6use serde::Deserialize;
7
8use super::traits::*;
9
10pub struct TodoWriteTool {
11 workspace: PathBuf,
12}
13
14impl TodoWriteTool {
15 pub fn new(workspace: PathBuf) -> Self {
16 Self { workspace }
17 }
18
19 fn todo_path(&self, file: &str) -> PathBuf {
20 let name = if file.is_empty() { "TODO" } else { file };
21 let clean: String = name
23 .chars()
24 .filter(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'))
25 .collect();
26 let clean = if clean.is_empty() {
27 "TODO".to_string()
28 } else {
29 clean
30 };
31 let name = if clean.ends_with(".md") {
32 clean
33 } else {
34 format!("{}.md", clean)
35 };
36 self.workspace.join(".tasks").join(name)
37 }
38}
39
40#[derive(Deserialize)]
41struct TodoArgs {
42 #[serde(default = "default_action")]
44 action: String,
45 #[serde(default)]
47 content: String,
48 #[serde(default)]
50 file: String,
51}
52
53fn default_action() -> String {
54 "append".to_string()
55}
56
57#[async_trait]
58impl Tool for TodoWriteTool {
59 fn name(&self) -> &str {
60 "todo"
61 }
62
63 fn spec(&self) -> ToolSpec {
64 ToolSpec {
65 name: "todo".to_string(),
66 description: "Create, append to, or read markdown task/todo files in .tasks/. \
67 Use to track work items, record decisions, or maintain checklists."
68 .to_string(),
69 parameters: serde_json::json!({
70 "type": "object",
71 "properties": {
72 "action": {
73 "type": "string",
74 "enum": ["write", "append", "read"],
75 "description": "write = overwrite file, append = add to end, read = show contents"
76 },
77 "content": {
78 "type": "string",
79 "description": "Content to write or append. Supports markdown."
80 },
81 "file": {
82 "type": "string",
83 "description": "Filename without .md extension (default: TODO)"
84 }
85 },
86 "required": ["action"]
87 }),
88 }
89 }
90
91 async fn execute(&self, arguments: &str) -> anyhow::Result<ToolResult> {
92 let args: TodoArgs = serde_json::from_str(arguments)?;
93 let path = self.todo_path(&args.file);
94
95 match args.action.as_str() {
96 "read" => match tokio::fs::read_to_string(&path).await {
97 Ok(content) => Ok(ToolResult::success(content)),
98 Err(_) => Ok(ToolResult::success("(file does not exist yet)")),
99 },
100 "write" => {
101 if let Some(parent) = path.parent() {
102 tokio::fs::create_dir_all(parent).await?;
103 }
104 tokio::fs::write(&path, &args.content).await?;
105 Ok(ToolResult::success(format!(
106 "Written to {}",
107 path.display()
108 )))
109 }
110 "append" => {
111 if let Some(parent) = path.parent() {
112 tokio::fs::create_dir_all(parent).await?;
113 }
114 let existing = tokio::fs::read_to_string(&path).await.unwrap_or_default();
115 let separator = if existing.is_empty() || existing.ends_with('\n') {
116 ""
117 } else {
118 "\n"
119 };
120 let new_content = format!("{}{}{}\n", existing, separator, args.content);
121 tokio::fs::write(&path, &new_content).await?;
122 Ok(ToolResult::success(format!(
123 "Appended to {}",
124 path.display()
125 )))
126 }
127 other => Ok(ToolResult::error(format!("Unknown action: {}", other))),
128 }
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135 use tempfile::TempDir;
136
137 #[tokio::test]
138 async fn write_and_read() {
139 let dir = TempDir::new().unwrap();
140 let tool = TodoWriteTool::new(dir.path().to_path_buf());
141
142 let w = tool
143 .execute(r#"{"action":"write","content":"- [ ] task one"}"#)
144 .await
145 .unwrap();
146 assert!(!w.is_error);
147
148 let r = tool.execute(r#"{"action":"read"}"#).await.unwrap();
149 assert!(r.output.contains("task one"));
150 }
151
152 #[tokio::test]
153 async fn append_adds_line() {
154 let dir = TempDir::new().unwrap();
155 let tool = TodoWriteTool::new(dir.path().to_path_buf());
156
157 tool.execute(r#"{"action":"write","content":"line1"}"#)
158 .await
159 .unwrap();
160 tool.execute(r#"{"action":"append","content":"line2"}"#)
161 .await
162 .unwrap();
163
164 let r = tool.execute(r#"{"action":"read"}"#).await.unwrap();
165 assert!(r.output.contains("line1"));
166 assert!(r.output.contains("line2"));
167 }
168}