Skip to main content

sparrow/tools/
todo.rs

1use async_trait::async_trait;
2use serde_json::json;
3use std::sync::Mutex;
4
5use super::{Tool, ToolCtx, ToolResult};
6use crate::event::RiskLevel;
7
8pub struct Todo {
9    db: Mutex<Option<rusqlite::Connection>>,
10}
11
12impl Todo {
13    pub fn new() -> Self {
14        Self {
15            db: Mutex::new(None),
16        }
17    }
18
19    fn get_conn(&self) -> anyhow::Result<std::sync::MutexGuard<'_, Option<rusqlite::Connection>>> {
20        let mut guard = self
21            .db
22            .lock()
23            .map_err(|_| anyhow::anyhow!("Todo DB lock poisoned"))?;
24        if guard.is_none() {
25            let state_dir = dirs::state_dir().unwrap_or_default().join("sparrow");
26            std::fs::create_dir_all(&state_dir)?;
27            let conn = rusqlite::Connection::open(state_dir.join("sparrow.db"))?;
28            conn.execute_batch(
29                "CREATE TABLE IF NOT EXISTS todos (
30                    id TEXT PRIMARY KEY,
31                    content TEXT NOT NULL,
32                    status TEXT NOT NULL DEFAULT 'pending',
33                    created_at INTEGER NOT NULL DEFAULT (unixepoch()),
34                    updated_at INTEGER NOT NULL DEFAULT (unixepoch())
35                );",
36            )?;
37            *guard = Some(conn);
38        }
39        Ok(guard)
40    }
41}
42
43#[async_trait]
44impl Tool for Todo {
45    fn name(&self) -> &str {
46        "todo"
47    }
48    fn description(&self) -> &str {
49        "Track tasks with persistent state across calls"
50    }
51    fn schema(&self) -> serde_json::Value {
52        json!({
53            "type": "object",
54            "properties": {
55                "action": { "type": "string", "enum": ["create", "update", "list", "complete", "delete", "clear_completed"] },
56                "id": { "type": "string" },
57                "content": { "type": "string" },
58                "status": { "type": "string", "enum": ["pending", "in_progress", "completed", "cancelled"] }
59            },
60            "required": ["action"]
61        })
62    }
63    fn risk(&self) -> RiskLevel {
64        RiskLevel::ReadOnly
65    }
66
67    async fn call(&self, args: serde_json::Value, _ctx: &ToolCtx) -> anyhow::Result<ToolResult> {
68        let action = args["action"].as_str().unwrap_or("list");
69        let content = args["content"].as_str().unwrap_or("");
70        let status = args["status"].as_str().unwrap_or("pending");
71        let id = args["id"].as_str().unwrap_or("");
72
73        let guard = self.get_conn()?;
74        let conn = guard
75            .as_ref()
76            .ok_or_else(|| anyhow::anyhow!("Todo DB not initialized"))?;
77
78        match action {
79            "create" => {
80                let new_id = uuid::Uuid::new_v4().to_string();
81                conn.execute(
82                    "INSERT INTO todos (id, content, status) VALUES (?1, ?2, ?3)",
83                    rusqlite::params![new_id, content, status],
84                )?;
85                Ok(ToolResult::text(format!(
86                    "Created task {}: {} ({})",
87                    new_id, content, status
88                )))
89            }
90            "update" => {
91                let rows = conn.execute(
92                    "UPDATE todos SET content = ?1, status = ?2, updated_at = unixepoch() WHERE id = ?3",
93                    rusqlite::params![content, status, id],
94                )?;
95                if rows == 0 {
96                    Ok(ToolResult::error(format!("Task {} not found", id)))
97                } else {
98                    Ok(ToolResult::text(format!(
99                        "Updated task {}: {} ({})",
100                        id, content, status
101                    )))
102                }
103            }
104            "complete" => {
105                let rows = conn.execute(
106                    "UPDATE todos SET status = 'completed', updated_at = unixepoch() WHERE id = ?1",
107                    rusqlite::params![id],
108                )?;
109                if rows == 0 {
110                    Ok(ToolResult::error(format!("Task {} not found", id)))
111                } else {
112                    Ok(ToolResult::text(format!("Completed task: {}", id)))
113                }
114            }
115            "delete" => {
116                conn.execute("DELETE FROM todos WHERE id = ?1", rusqlite::params![id])?;
117                Ok(ToolResult::text(format!("Deleted task: {}", id)))
118            }
119            "clear_completed" => {
120                let count = conn.execute("DELETE FROM todos WHERE status = 'completed'", [])?;
121                Ok(ToolResult::text(format!(
122                    "Cleared {} completed tasks",
123                    count
124                )))
125            }
126            _ => {
127                // list — optionally filtered by status
128                let mut stmt = if !status.is_empty() && status != "pending" {
129                    conn.prepare("SELECT id, content, status FROM todos WHERE status = ?1 ORDER BY created_at")?
130                } else {
131                    conn.prepare("SELECT id, content, status FROM todos ORDER BY created_at")?
132                };
133                let rows: Vec<String> = if !status.is_empty() && status != "pending" {
134                    stmt.query_map(rusqlite::params![status], |row| {
135                        Ok(format!(
136                            "  {} [{}] {}",
137                            row.get::<_, String>(0)?,
138                            row.get::<_, String>(2)?,
139                            row.get::<_, String>(1)?
140                        ))
141                    })?
142                    .filter_map(|r| r.ok())
143                    .collect()
144                } else {
145                    stmt.query_map([], |row| {
146                        Ok(format!(
147                            "  {} [{}] {}",
148                            row.get::<_, String>(0)?,
149                            row.get::<_, String>(2)?,
150                            row.get::<_, String>(1)?
151                        ))
152                    })?
153                    .filter_map(|r| r.ok())
154                    .collect()
155                };
156                if rows.is_empty() {
157                    Ok(ToolResult::text("No tasks."))
158                } else {
159                    Ok(ToolResult::text(rows.join("\n")))
160                }
161            }
162        }
163    }
164}
165
166impl Default for Todo {
167    fn default() -> Self {
168        Self::new()
169    }
170}