Skip to main content

rustant_tools/
inbox.rs

1//! Inbox tool — quick capture for tasks, ideas, and notes.
2
3use async_trait::async_trait;
4use chrono::{DateTime, Utc};
5use rustant_core::error::ToolError;
6use rustant_core::types::{RiskLevel, ToolOutput};
7use serde::{Deserialize, Serialize};
8use serde_json::{Value, json};
9use std::path::PathBuf;
10
11use crate::registry::Tool;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14struct InboxItem {
15    id: usize,
16    text: String,
17    #[serde(default)]
18    tags: Vec<String>,
19    created_at: DateTime<Utc>,
20    #[serde(default)]
21    done: bool,
22}
23
24#[derive(Debug, Default, Serialize, Deserialize)]
25struct InboxState {
26    items: Vec<InboxItem>,
27    next_id: usize,
28}
29
30pub struct InboxTool {
31    workspace: PathBuf,
32}
33
34impl InboxTool {
35    pub fn new(workspace: PathBuf) -> Self {
36        Self { workspace }
37    }
38
39    fn state_path(&self) -> PathBuf {
40        self.workspace
41            .join(".rustant")
42            .join("inbox")
43            .join("items.json")
44    }
45
46    fn load_state(&self) -> InboxState {
47        let path = self.state_path();
48        if path.exists() {
49            std::fs::read_to_string(&path)
50                .ok()
51                .and_then(|s| serde_json::from_str(&s).ok())
52                .unwrap_or_default()
53        } else {
54            InboxState {
55                items: Vec::new(),
56                next_id: 1,
57            }
58        }
59    }
60
61    fn save_state(&self, state: &InboxState) -> Result<(), ToolError> {
62        let path = self.state_path();
63        if let Some(parent) = path.parent() {
64            std::fs::create_dir_all(parent).map_err(|e| ToolError::ExecutionFailed {
65                name: "inbox".to_string(),
66                message: format!("Failed to create dir: {}", e),
67            })?;
68        }
69        let json = serde_json::to_string_pretty(state).map_err(|e| ToolError::ExecutionFailed {
70            name: "inbox".to_string(),
71            message: format!("Serialize error: {}", e),
72        })?;
73        let tmp = path.with_extension("json.tmp");
74        std::fs::write(&tmp, &json).map_err(|e| ToolError::ExecutionFailed {
75            name: "inbox".to_string(),
76            message: format!("Write error: {}", e),
77        })?;
78        std::fs::rename(&tmp, &path).map_err(|e| ToolError::ExecutionFailed {
79            name: "inbox".to_string(),
80            message: format!("Rename error: {}", e),
81        })?;
82        Ok(())
83    }
84}
85
86#[async_trait]
87impl Tool for InboxTool {
88    fn name(&self) -> &str {
89        "inbox"
90    }
91    fn description(&self) -> &str {
92        "Quick capture inbox for tasks, ideas, and notes. Actions: add, list, search, clear, tag, done."
93    }
94    fn parameters_schema(&self) -> Value {
95        json!({
96            "type": "object",
97            "properties": {
98                "action": {
99                    "type": "string",
100                    "enum": ["add", "list", "search", "clear", "tag", "done"],
101                    "description": "Action to perform"
102                },
103                "text": { "type": "string", "description": "Item text (for add/search)" },
104                "id": { "type": "integer", "description": "Item ID (for tag/done)" },
105                "tag": { "type": "string", "description": "Tag name (for tag action)" }
106            },
107            "required": ["action"]
108        })
109    }
110    fn risk_level(&self) -> RiskLevel {
111        RiskLevel::Write
112    }
113
114    async fn execute(&self, args: Value) -> Result<ToolOutput, ToolError> {
115        let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("");
116        let mut state = self.load_state();
117
118        match action {
119            "add" => {
120                let text = args
121                    .get("text")
122                    .and_then(|v| v.as_str())
123                    .unwrap_or("")
124                    .trim();
125                if text.is_empty() {
126                    return Ok(ToolOutput::text("Please provide text for the inbox item."));
127                }
128                let id = state.next_id;
129                state.next_id += 1;
130                state.items.push(InboxItem {
131                    id,
132                    text: text.to_string(),
133                    tags: Vec::new(),
134                    created_at: Utc::now(),
135                    done: false,
136                });
137                self.save_state(&state)?;
138                Ok(ToolOutput::text(format!("Added to inbox (#{}).", id)))
139            }
140            "list" => {
141                let active: Vec<&InboxItem> = state.items.iter().filter(|i| !i.done).collect();
142                if active.is_empty() {
143                    return Ok(ToolOutput::text("Inbox is empty."));
144                }
145                let lines: Vec<String> = active
146                    .iter()
147                    .map(|i| {
148                        let tags = if i.tags.is_empty() {
149                            String::new()
150                        } else {
151                            format!(" [{}]", i.tags.join(", "))
152                        };
153                        format!("  #{} — {}{}", i.id, i.text, tags)
154                    })
155                    .collect();
156                Ok(ToolOutput::text(format!(
157                    "Inbox ({} items):\n{}",
158                    active.len(),
159                    lines.join("\n")
160                )))
161            }
162            "search" => {
163                let query = args
164                    .get("text")
165                    .and_then(|v| v.as_str())
166                    .unwrap_or("")
167                    .to_lowercase();
168                let matches: Vec<String> = state
169                    .items
170                    .iter()
171                    .filter(|i| {
172                        i.text.to_lowercase().contains(&query)
173                            || i.tags.iter().any(|t| t.to_lowercase().contains(&query))
174                    })
175                    .map(|i| {
176                        format!(
177                            "  #{} — {} {}",
178                            i.id,
179                            i.text,
180                            if i.done { "(done)" } else { "" }
181                        )
182                    })
183                    .collect();
184                if matches.is_empty() {
185                    Ok(ToolOutput::text(format!("No items matching '{}'.", query)))
186                } else {
187                    Ok(ToolOutput::text(format!(
188                        "Found {} items:\n{}",
189                        matches.len(),
190                        matches.join("\n")
191                    )))
192                }
193            }
194            "tag" => {
195                let id = args.get("id").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
196                let tag = args.get("tag").and_then(|v| v.as_str()).unwrap_or("");
197                if tag.is_empty() {
198                    return Ok(ToolOutput::text("Please provide a tag."));
199                }
200                if let Some(item) = state.items.iter_mut().find(|i| i.id == id) {
201                    if !item.tags.contains(&tag.to_string()) {
202                        item.tags.push(tag.to_string());
203                    }
204                    self.save_state(&state)?;
205                    Ok(ToolOutput::text(format!("Tagged #{} with '{}'.", id, tag)))
206                } else {
207                    Ok(ToolOutput::text(format!("Item #{} not found.", id)))
208                }
209            }
210            "done" => {
211                let id = args.get("id").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
212                if let Some(item) = state.items.iter_mut().find(|i| i.id == id) {
213                    item.done = true;
214                    self.save_state(&state)?;
215                    Ok(ToolOutput::text(format!("Marked #{} as done.", id)))
216                } else {
217                    Ok(ToolOutput::text(format!("Item #{} not found.", id)))
218                }
219            }
220            "clear" => {
221                let count = state.items.iter().filter(|i| i.done).count();
222                state.items.retain(|i| !i.done);
223                self.save_state(&state)?;
224                Ok(ToolOutput::text(format!(
225                    "Cleared {} completed items.",
226                    count
227                )))
228            }
229            _ => Ok(ToolOutput::text(format!(
230                "Unknown action: {}. Use: add, list, search, clear, tag, done",
231                action
232            ))),
233        }
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use tempfile::TempDir;
241
242    #[tokio::test]
243    async fn test_inbox_add_list() {
244        let dir = TempDir::new().unwrap();
245        let workspace = dir.path().canonicalize().unwrap();
246        let tool = InboxTool::new(workspace);
247
248        tool.execute(json!({"action": "add", "text": "Buy groceries"}))
249            .await
250            .unwrap();
251        tool.execute(json!({"action": "add", "text": "Call dentist"}))
252            .await
253            .unwrap();
254
255        let result = tool.execute(json!({"action": "list"})).await.unwrap();
256        assert!(result.content.contains("Buy groceries"));
257        assert!(result.content.contains("Call dentist"));
258        assert!(result.content.contains("2 items"));
259    }
260
261    #[tokio::test]
262    async fn test_inbox_done_clear() {
263        let dir = TempDir::new().unwrap();
264        let workspace = dir.path().canonicalize().unwrap();
265        let tool = InboxTool::new(workspace);
266
267        tool.execute(json!({"action": "add", "text": "Task 1"}))
268            .await
269            .unwrap();
270        tool.execute(json!({"action": "done", "id": 1}))
271            .await
272            .unwrap();
273
274        let result = tool.execute(json!({"action": "clear"})).await.unwrap();
275        assert!(result.content.contains("Cleared 1"));
276    }
277
278    #[tokio::test]
279    async fn test_inbox_search() {
280        let dir = TempDir::new().unwrap();
281        let workspace = dir.path().canonicalize().unwrap();
282        let tool = InboxTool::new(workspace);
283
284        tool.execute(json!({"action": "add", "text": "Review PR #42"}))
285            .await
286            .unwrap();
287        tool.execute(json!({"action": "add", "text": "Fix bug in parser"}))
288            .await
289            .unwrap();
290
291        let result = tool
292            .execute(json!({"action": "search", "text": "PR"}))
293            .await
294            .unwrap();
295        assert!(result.content.contains("Review PR"));
296        assert!(!result.content.contains("parser"));
297    }
298
299    #[tokio::test]
300    async fn test_inbox_schema() {
301        let dir = TempDir::new().unwrap();
302        let tool = InboxTool::new(dir.path().to_path_buf());
303        let schema = tool.parameters_schema();
304        assert!(schema.get("properties").is_some());
305    }
306}