1use serde_json::Value;
16use std::path::PathBuf;
17
18use crate::protocol::ToolCallResult;
19use crate::server::ServerContext;
20use crate::tools::format::{format_item_summary, html_to_text};
21
22use super::resolve_citekey;
23
24pub fn zotero_status(ctx: &ServerContext) -> ToolCallResult {
29 let zdb = match ctx.db.zotero() {
30 Ok(db) => db,
31 Err(e) => return ToolCallResult::error(e.to_string()),
32 };
33
34 let items = zdb.item_count().unwrap_or(0);
35 let collections = zdb.collection_count().unwrap_or(0);
36 let bbt_status = if ctx.db.bbt.is_some() {
37 "connected"
38 } else {
39 "unavailable"
40 };
41 let write_status = if ctx.config.has_write_access() {
42 "enabled"
43 } else {
44 "disabled (no API key)"
45 };
46
47 ToolCallResult::text(format!(
48 "Zotero MCP (Rust)\n\
49 Items: {items}\n\
50 Collections: {collections}\n\
51 BBT database: {bbt_status}\n\
52 Write access: {write_status}\n\
53 Version: {}",
54 env!("CARGO_PKG_VERSION")
55 ))
56}
57
58pub fn zotero_search(args: &Value, ctx: &ServerContext) -> ToolCallResult {
63 let query = match args.get("query").and_then(|v| v.as_str()) {
64 Some(q) => q,
65 None => return ToolCallResult::error("Missing required parameter: query".into()),
66 };
67 let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
68
69 let zdb = match ctx.db.zotero() {
70 Ok(db) => db,
71 Err(e) => return ToolCallResult::error(e.to_string()),
72 };
73
74 let results = match zdb.search_items(query, limit) {
75 Ok(r) => r,
76 Err(e) => return ToolCallResult::error(format!("Search failed: {e}")),
77 };
78
79 if results.is_empty() {
80 return ToolCallResult::text(format!("No items found for query: {query}"));
81 }
82
83 let mut output = format!("Found {} item(s) for '{query}':\n", results.len());
84
85 for (_item_id, item_key) in &results {
86 if let Ok(Some(item)) = zdb.item_by_key(item_key) {
87 let citekey = ctx.db.citekey_for_item_key(item_key);
88 output.push('\n');
89 output.push_str(&format_item_summary(&item, citekey.as_deref()));
90 output.push_str("\n\n---\n");
91 }
92 }
93
94 ToolCallResult::text(output)
95}
96
97pub fn zotero_get_item(args: &Value, ctx: &ServerContext) -> ToolCallResult {
102 let citekey = match args.get("citekey").and_then(|v| v.as_str()) {
103 Some(ck) => ck,
104 None => return ToolCallResult::error("Missing required parameter: citekey".into()),
105 };
106
107 let item_key = match resolve_citekey(ctx, citekey) {
108 Ok(k) => k,
109 Err(e) => return ToolCallResult::error(e),
110 };
111
112 let zdb = match ctx.db.zotero() {
113 Ok(db) => db,
114 Err(e) => return ToolCallResult::error(e.to_string()),
115 };
116
117 match zdb.item_by_key(&item_key) {
118 Ok(Some(item)) => {
119 let mut output = format_item_summary(&item, Some(citekey));
120 if let Some(abs) = &item.abstract_note {
121 output.push_str(&format!("\n\nAbstract: {abs}"));
122 }
123 if !item.tags.is_empty() {
124 output.push_str(&format!("\nTags: {}", item.tags.join(", ")));
125 }
126 ToolCallResult::text(output)
127 }
128 Ok(None) => ToolCallResult::error(format!("Item not found: {item_key}")),
129 Err(e) => ToolCallResult::error(format!("Database error: {e}")),
130 }
131}
132
133pub fn zotero_get_notes(args: &Value, ctx: &ServerContext) -> ToolCallResult {
138 let citekey = match args.get("citekey").and_then(|v| v.as_str()) {
139 Some(ck) => ck,
140 None => return ToolCallResult::error("Missing required parameter: citekey".into()),
141 };
142
143 let item_key = match resolve_citekey(ctx, citekey) {
144 Ok(k) => k,
145 Err(e) => return ToolCallResult::error(e),
146 };
147
148 let zdb = match ctx.db.zotero() {
149 Ok(db) => db,
150 Err(e) => return ToolCallResult::error(e.to_string()),
151 };
152
153 let item = match zdb.item_by_key(&item_key) {
155 Ok(Some(item)) => item,
156 Ok(None) => return ToolCallResult::error(format!("Item not found: {item_key}")),
157 Err(e) => return ToolCallResult::error(e.to_string()),
158 };
159
160 match zdb.item_notes(item.item_id) {
161 Ok(notes) if notes.is_empty() => ToolCallResult::text(format!("No notes for {citekey}")),
162 Ok(notes) => {
163 let mut output = format!("{} note(s) for {citekey}:\n\n", notes.len());
164 for (i, note) in notes.iter().enumerate() {
165 output.push_str(&format!(
166 "--- Note {} ---\n{}\n\n",
167 i + 1,
168 html_to_text(note)
169 ));
170 }
171 ToolCallResult::text(output)
172 }
173 Err(e) => ToolCallResult::error(format!("Error reading notes: {e}")),
174 }
175}
176
177pub fn zotero_get_pdf_path(args: &Value, ctx: &ServerContext) -> ToolCallResult {
182 let citekey = match args.get("citekey").and_then(|v| v.as_str()) {
183 Some(ck) => ck,
184 None => return ToolCallResult::error("Missing required parameter: citekey".into()),
185 };
186
187 let item_key = match resolve_citekey(ctx, citekey) {
188 Ok(k) => k,
189 Err(e) => return ToolCallResult::error(e),
190 };
191
192 let zdb = match ctx.db.zotero() {
193 Ok(db) => db,
194 Err(e) => return ToolCallResult::error(e.to_string()),
195 };
196
197 let item = match zdb.item_by_key(&item_key) {
198 Ok(Some(item)) => item,
199 Ok(None) => return ToolCallResult::error(format!("Item not found: {item_key}")),
200 Err(e) => return ToolCallResult::error(e.to_string()),
201 };
202
203 let attachments = match zdb.item_attachments(item.item_id) {
204 Ok(a) => a,
205 Err(e) => return ToolCallResult::error(e.to_string()),
206 };
207
208 let pdf_entries: Vec<String> = attachments
209 .iter()
210 .filter(|a| a.content_type == "application/pdf")
211 .filter_map(|a| {
212 a.path.as_ref().map(|p| {
213 let resolved = if let Some(filename) = p.strip_prefix("storage:") {
214 let full_path: PathBuf = [
215 ctx.config.zotero_storage_path.to_str().unwrap_or(""),
216 &a.item_key,
217 filename,
218 ]
219 .iter()
220 .collect();
221 full_path.to_string_lossy().to_string()
222 } else {
223 p.clone()
224 };
225 match &a.storage_hash {
226 Some(hash) => format!("{resolved}\n md5:{hash}"),
227 None => resolved,
228 }
229 })
230 })
231 .collect();
232
233 if pdf_entries.is_empty() {
234 ToolCallResult::text(format!("No PDF attachments for {citekey}"))
235 } else {
236 ToolCallResult::text(pdf_entries.join("\n"))
237 }
238}
239
240pub fn zotero_list_attachments(args: &Value, ctx: &ServerContext) -> ToolCallResult {
245 let citekey = match args.get("citekey").and_then(|v| v.as_str()) {
246 Some(ck) => ck,
247 None => return ToolCallResult::error("Missing required parameter: citekey".into()),
248 };
249
250 let item_key = match resolve_citekey(ctx, citekey) {
251 Ok(k) => k,
252 Err(e) => return ToolCallResult::error(e),
253 };
254
255 let zdb = match ctx.db.zotero() {
256 Ok(db) => db,
257 Err(e) => return ToolCallResult::error(e.to_string()),
258 };
259
260 let item = match zdb.item_by_key(&item_key) {
261 Ok(Some(item)) => item,
262 Ok(None) => return ToolCallResult::error(format!("Item not found: {item_key}")),
263 Err(e) => return ToolCallResult::error(e.to_string()),
264 };
265
266 match zdb.item_attachments(item.item_id) {
267 Ok(attachments) if attachments.is_empty() => {
268 ToolCallResult::text(format!("No attachments for {citekey}"))
269 }
270 Ok(attachments) => {
271 let mut output = format!("{} attachment(s) for {citekey}:\n\n", attachments.len());
272 for att in &attachments {
273 let title = att.title.as_deref().unwrap_or("(untitled)");
274 let path = att.path.as_deref().unwrap_or("(no path)");
275 let hash = att
276 .storage_hash
277 .as_deref()
278 .map(|h| format!("\n md5:{h}"))
279 .unwrap_or_default();
280 output.push_str(&format!(
281 "- [{title}] {}\n {path}{hash}\n",
282 att.content_type
283 ));
284 }
285 ToolCallResult::text(output)
286 }
287 Err(e) => ToolCallResult::error(format!("Error listing attachments: {e}")),
288 }
289}
290
291pub fn zotero_get_collections(ctx: &ServerContext) -> ToolCallResult {
296 let zdb = match ctx.db.zotero() {
297 Ok(db) => db,
298 Err(e) => return ToolCallResult::error(e.to_string()),
299 };
300
301 match zdb.collections() {
302 Ok(collections) => {
303 let mut output = format!("{} collection(s):\n\n", collections.len());
304 for coll in &collections {
305 let parent = coll.parent_key.as_deref().unwrap_or("-");
306 output.push_str(&format!(
307 "- {} (key: {}, parent: {})\n",
308 coll.name, coll.key, parent
309 ));
310 }
311 ToolCallResult::text(output)
312 }
313 Err(e) => ToolCallResult::error(format!("Error listing collections: {e}")),
314 }
315}
316
317pub fn zotero_get_collection_items(args: &Value, ctx: &ServerContext) -> ToolCallResult {
322 let collection_key = match args.get("collection_key").and_then(|v| v.as_str()) {
323 Some(k) => k,
324 None => return ToolCallResult::error("Missing required parameter: collection_key".into()),
325 };
326 let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
327
328 let zdb = match ctx.db.zotero() {
329 Ok(db) => db,
330 Err(e) => return ToolCallResult::error(e.to_string()),
331 };
332
333 let items = match zdb.collection_items(collection_key, limit) {
334 Ok(i) => i,
335 Err(e) => return ToolCallResult::error(format!("Error: {e}")),
336 };
337
338 if items.is_empty() {
339 return ToolCallResult::text(format!("No items in collection {collection_key}"));
340 }
341
342 let mut output = format!("Collection {} ({} item(s)):\n", collection_key, items.len());
343 for (_item_id, item_key) in &items {
344 if let Ok(Some(item)) = zdb.item_by_key(item_key) {
345 let citekey = ctx.db.citekey_for_item_key(item_key);
346 output.push('\n');
347 output.push_str(&format_item_summary(&item, citekey.as_deref()));
348 output.push_str("\n\n---\n");
349 }
350 }
351
352 ToolCallResult::text(output)
353}
354
355pub fn zotero_get_recent(args: &Value, ctx: &ServerContext) -> ToolCallResult {
360 let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
361
362 let zdb = match ctx.db.zotero() {
363 Ok(db) => db,
364 Err(e) => return ToolCallResult::error(e.to_string()),
365 };
366
367 let items = match zdb.recent_items(limit) {
368 Ok(i) => i,
369 Err(e) => return ToolCallResult::error(format!("Error: {e}")),
370 };
371
372 if items.is_empty() {
373 return ToolCallResult::text("No recent items".into());
374 }
375
376 let mut output = format!("{} recent item(s):\n", items.len());
377 for (_item_id, item_key) in &items {
378 if let Ok(Some(item)) = zdb.item_by_key(item_key) {
379 let citekey = ctx.db.citekey_for_item_key(item_key);
380 output.push('\n');
381 output.push_str(&format_item_summary(&item, citekey.as_deref()));
382 output.push_str("\n\n---\n");
383 }
384 }
385
386 ToolCallResult::text(output)
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392 use crate::test_helpers::test_ctx;
393 use serde_json::json;
394
395 #[test]
396 fn status_returns_item_count() {
397 let ctx = test_ctx();
398 let result = zotero_status(&ctx);
399 assert!(result.is_error.is_none());
400 let text = &result.content[0].text;
401 assert!(text.contains("Items: 2"), "Got: {text}");
402 assert!(text.contains("Collections: 1"), "Got: {text}");
403 }
404
405 #[test]
406 fn search_finds_by_title() {
407 let ctx = test_ctx();
408 let result = zotero_search(&json!({"query": "Hints", "limit": 10}), &ctx);
409 let text = &result.content[0].text;
410 assert!(text.contains("demilloHintsTestData1978"), "Got: {text}");
411 }
412
413 #[test]
414 fn search_no_results() {
415 let ctx = test_ctx();
416 let result = zotero_search(&json!({"query": "quantum computing", "limit": 10}), &ctx);
417 let text = &result.content[0].text;
418 assert!(text.contains("No items found"), "Got: {text}");
419 }
420
421 #[test]
422 fn search_missing_query_returns_error() {
423 let ctx = test_ctx();
424 let result = zotero_search(&json!({}), &ctx);
425 assert_eq!(result.is_error, Some(true));
426 }
427
428 #[test]
429 fn get_item_by_citekey() {
430 let ctx = test_ctx();
431 let result = zotero_get_item(&json!({"citekey": "demilloHintsTestData1978"}), &ctx);
432 let text = &result.content[0].text;
433 assert!(text.contains("Hints on Test Data Selection"), "Got: {text}");
434 assert!(text.contains("DeMillo"), "Got: {text}");
435 }
436
437 #[test]
438 fn get_item_unknown_citekey() {
439 let ctx = test_ctx();
440 let result = zotero_get_item(&json!({"citekey": "nonexistent2099"}), &ctx);
441 assert_eq!(result.is_error, Some(true));
442 }
443
444 #[test]
445 fn get_notes_found() {
446 let ctx = test_ctx();
447 let result = zotero_get_notes(&json!({"citekey": "demilloHintsTestData1978"}), &ctx);
448 let text = &result.content[0].text;
449 assert!(text.contains("foundational paper"), "Got: {text}");
450 }
451
452 #[test]
453 fn get_collections_lists_all() {
454 let ctx = test_ctx();
455 let result = zotero_get_collections(&ctx);
456 let text = &result.content[0].text;
457 assert!(text.contains("Mutation Testing"), "Got: {text}");
458 }
459
460 #[test]
461 fn get_collection_items_found() {
462 let ctx = test_ctx();
463 let result =
464 zotero_get_collection_items(&json!({"collection_key": "COL00001", "limit": 10}), &ctx);
465 let text = &result.content[0].text;
466 assert!(text.contains("demilloHintsTestData1978"), "Got: {text}");
467 }
468
469 #[test]
470 fn get_recent_returns_items() {
471 let ctx = test_ctx();
472 let result = zotero_get_recent(&json!({"limit": 5}), &ctx);
473 let text = &result.content[0].text;
474 assert!(text.contains("demilloHintsTestData1978"), "Got: {text}");
475 }
476
477 #[test]
478 fn get_pdf_path_found() {
479 let ctx = test_ctx();
480 let result = zotero_get_pdf_path(&json!({"citekey": "demilloHintsTestData1978"}), &ctx);
481 let text = &result.content[0].text;
482 assert!(text.contains("DeMillo1978.pdf"), "Got: {text}");
483 }
484
485 #[test]
486 fn list_attachments_empty() {
487 let ctx = test_ctx();
488 let result = zotero_list_attachments(&json!({"citekey": "artTesting2020"}), &ctx);
489 let text = &result.content[0].text;
490 assert!(text.contains("No attachments"), "Got: {text}");
491 }
492}