1use 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}