Skip to main content

apollo/tools/
todo_write.rs

1//! TodoWriteTool — create, append, and list markdown task files.
2
3use 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        // Sanitize: only allow filename-safe characters
22        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    /// Action: "write" (overwrite), "append", or "read"
43    #[serde(default = "default_action")]
44    action: String,
45    /// Items or content to write (one per line, or full markdown)
46    #[serde(default)]
47    content: String,
48    /// Optional filename without extension (defaults to "TODO")
49    #[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}