biblion/tools/
write.rs

1//! Write MCP tools — Zotero Web API operations.
2//!
3//! All write tools go through the Zotero Web API (never direct SQLite writes).
4//! They require `ZOTERO_API_KEY` to be set.
5//!
6//! # Latency
7//!
8//! Write tools have the same latency as the Python server (~200-500ms)
9//! because the bottleneck is the Zotero API, not our code. This is expected
10//! and acceptable — writes are rare in MCP usage.
11
12use serde_json::{Value, json};
13
14use crate::api::bbt_rpc::BbtRpcClient;
15use crate::api::zotero_web::ZoteroWebClient;
16use crate::protocol::ToolCallResult;
17use crate::server::ServerContext;
18
19/// Get write client. Checks both write-gate flag and API key.
20fn get_write_client(ctx: &ServerContext) -> Result<ZoteroWebClient, String> {
21    if !ctx.config.writes_enabled {
22        return Err("Write tools disabled. Set ZOTERO_MCP_ENABLE_WRITES=true to enable.".into());
23    }
24    let api_key =
25        ctx.config.zotero_api_key.as_deref().ok_or_else(|| {
26            "Write access requires ZOTERO_API_KEY environment variable.".to_string()
27        })?;
28    if let Some(base_url) = &ctx.config.zotero_api_base_url {
29        Ok(ZoteroWebClient::with_base_url(api_key, base_url))
30    } else {
31        Ok(ZoteroWebClient::new(
32            api_key,
33            &ctx.config.zotero_library_id,
34            &ctx.config.zotero_library_type,
35        ))
36    }
37}
38
39use super::resolve_citekey;
40
41/// Resolve an identifier that may be either a Zotero item key (8-char) or a BBT citekey.
42///
43/// Zotero item keys are 8 alphanumeric characters (e.g., "TQPUXSC2").
44/// Anything longer is treated as a citekey and resolved via BBT.
45/// We accept item keys without verifying in SQLite because freshly
46/// created items may not have synced to the local database yet.
47fn resolve_item_key(ctx: &ServerContext, key: &str) -> Result<String, String> {
48    // Zotero item keys are exactly 8 chars, alphanumeric
49    if key.len() == 8 && key.chars().all(|c| c.is_ascii_alphanumeric()) {
50        return Ok(key.to_string());
51    }
52    // Try as citekey
53    resolve_citekey(ctx, key)
54}
55
56// ---------------------------------------------------------------------------
57// BibTeX / Bibliography
58// ---------------------------------------------------------------------------
59
60/// Export items as BibTeX/BibLaTeX — native implementation, no BBT needed.
61///
62/// This reads directly from SQLite and generates BibTeX in <1ms.
63/// The Python server routed this through BBT JSON-RPC (~300ms).
64pub fn zotero_get_bibtex(args: &Value, ctx: &ServerContext) -> ToolCallResult {
65    let citekeys: Vec<&str> = match args.get("citekeys").and_then(|v| v.as_array()) {
66        Some(arr) => arr.iter().filter_map(|v| v.as_str()).collect(),
67        None => match args.get("citekey").and_then(|v| v.as_str()) {
68            Some(ck) => vec![ck],
69            None => return ToolCallResult::error("Missing parameter: citekeys or citekey".into()),
70        },
71    };
72    let format = match args
73        .get("format")
74        .and_then(|v| v.as_str())
75        .unwrap_or("bibtex")
76    {
77        f if f.to_lowercase().contains("biblatex") => "biblatex",
78        _ => "bibtex",
79    };
80
81    let zdb = match ctx.db.zotero() {
82        Ok(db) => db,
83        Err(e) => return ToolCallResult::error(e.to_string()),
84    };
85
86    let mut entries = Vec::new();
87    for citekey in &citekeys {
88        let item_key = match resolve_citekey(ctx, citekey) {
89            Ok(k) => k,
90            Err(e) => return ToolCallResult::error(e),
91        };
92        let item = match zdb.item_by_key(&item_key) {
93            Ok(Some(item)) => item,
94            Ok(None) => return ToolCallResult::error(format!("Item not found: {item_key}")),
95            Err(e) => return ToolCallResult::error(e.to_string()),
96        };
97        let metadata = zdb.item_metadata(item.item_id).unwrap_or_default();
98        entries.push((item, citekey.to_string(), metadata));
99    }
100
101    let result = super::bibtex::items_to_bibtex(&entries, format);
102    ToolCallResult::text(result)
103}
104
105/// Generate formatted bibliography — native for APA/IEEE, BBT fallback for others.
106///
107/// # Style resolution
108///
109/// 1. If style is APA or IEEE → native formatting (sub-millisecond, no Zotero needed)
110/// 2. If style is anything else → BBT JSON-RPC fallback (requires Zotero running)
111///
112/// # Reference
113///
114/// APA implementation follows: <https://apastyle.apa.org/style-grammar-guidelines/references>
115/// IEEE implementation follows: <https://ieeeauthorcenter.ieee.org/wp-content/uploads/IEEE-Reference-Guide.pdf>
116/// For verification against the reference CSL engine, compare with BBT's output.
117pub fn zotero_get_bibliography(args: &Value, ctx: &ServerContext) -> ToolCallResult {
118    let citekeys: Vec<&str> = match args.get("citekeys").and_then(|v| v.as_array()) {
119        Some(arr) => arr.iter().filter_map(|v| v.as_str()).collect(),
120        None => return ToolCallResult::error("Missing parameter: citekeys".into()),
121    };
122    let style = args
123        .get("style")
124        .and_then(|v| v.as_str())
125        .unwrap_or("http://www.zotero.org/styles/apa");
126
127    // Try native formatting for supported styles
128    if super::bibliography::is_native_style(style) {
129        let zdb = match ctx.db.zotero() {
130            Ok(db) => db,
131            Err(e) => return ToolCallResult::error(e.to_string()),
132        };
133
134        let mut items = Vec::new();
135        for citekey in &citekeys {
136            let item_key = match resolve_citekey(ctx, citekey) {
137                Ok(k) => k,
138                Err(e) => return ToolCallResult::error(e),
139            };
140            let item = match zdb.item_by_key(&item_key) {
141                Ok(Some(item)) => item,
142                Ok(None) => return ToolCallResult::error(format!("Item not found: {item_key}")),
143                Err(e) => return ToolCallResult::error(e.to_string()),
144            };
145            let metadata = zdb.item_metadata(item.item_id).unwrap_or_default();
146            items.push((item, metadata));
147        }
148
149        let result = super::bibliography::format_bibliography_list(&items, style);
150        return ToolCallResult::text(result);
151    }
152
153    // Fallback to BBT for unsupported styles (requires Zotero running)
154    let bbt = BbtRpcClient::new(&ctx.config.bbt_url);
155    match bbt.bibliography(&citekeys, style) {
156        Ok(result) => ToolCallResult::text(result),
157        Err(e) => ToolCallResult::error(format!(
158            "Style '{style}' not supported natively. BBT fallback failed (is Zotero running?): {e}"
159        )),
160    }
161}
162
163/// Export a collection or item list as BibTeX/BibLaTeX — native, no BBT needed.
164pub fn zotero_export_bibtex(args: &Value, ctx: &ServerContext) -> ToolCallResult {
165    let format = match args
166        .get("format")
167        .and_then(|v| v.as_str())
168        .unwrap_or("biblatex")
169    {
170        f if f.to_lowercase().contains("biblatex") => "biblatex",
171        _ => "bibtex",
172    };
173
174    let zdb = match ctx.db.zotero() {
175        Ok(db) => db,
176        Err(e) => return ToolCallResult::error(e.to_string()),
177    };
178
179    // Get item keys from collection or explicit list
180    let item_keys: Vec<(i64, String)> =
181        if let Some(collection_key) = args.get("collection_key").and_then(|v| v.as_str()) {
182            match zdb.collection_items(collection_key, 1000) {
183                Ok(items) => items,
184                Err(e) => return ToolCallResult::error(e.to_string()),
185            }
186        } else if let Some(keys) = args.get("item_keys").and_then(|v| v.as_array()) {
187            keys.iter()
188                .filter_map(|v| v.as_str())
189                .filter_map(|key| {
190                    zdb.item_by_key(key)
191                        .ok()
192                        .flatten()
193                        .map(|item| (item.item_id, item.item_key))
194                })
195                .collect()
196        } else {
197            return ToolCallResult::error("Provide either collection_key or item_keys".into());
198        };
199
200    if item_keys.is_empty() {
201        return ToolCallResult::text("No items found to export.".into());
202    }
203
204    let mut entries = Vec::new();
205    for (_item_id, item_key) in &item_keys {
206        if let Ok(Some(item)) = zdb.item_by_key(item_key) {
207            let citekey = ctx
208                .db
209                .citekey_for_item_key(item_key)
210                .unwrap_or_else(|| item_key.clone());
211            let metadata = zdb.item_metadata(item.item_id).unwrap_or_default();
212            entries.push((item, citekey, metadata));
213        }
214    }
215
216    let result = super::bibtex::items_to_bibtex(&entries, format);
217    ToolCallResult::text(result)
218}
219
220// ---------------------------------------------------------------------------
221// Create / Update / Delete items
222// ---------------------------------------------------------------------------
223
224pub fn zotero_create_item(args: &Value, ctx: &ServerContext) -> ToolCallResult {
225    let client = match get_write_client(ctx) {
226        Ok(c) => c,
227        Err(e) => return ToolCallResult::error(e),
228    };
229
230    let item_type = match args.get("item_type").and_then(|v| v.as_str()) {
231        Some(t) => t,
232        None => return ToolCallResult::error("Missing parameter: item_type".into()),
233    };
234    let title = match args.get("title").and_then(|v| v.as_str()) {
235        Some(t) => t,
236        None => return ToolCallResult::error("Missing parameter: title".into()),
237    };
238
239    // Get template
240    let mut template = match client.item_template(item_type) {
241        Ok(t) => t,
242        Err(e) => return ToolCallResult::error(format!("Failed to get template: {e}")),
243    };
244
245    template["title"] = json!(title);
246
247    // Set creators
248    if let Some(creators) = args.get("creators").and_then(|v| v.as_array()) {
249        template["creators"] = json!(creators);
250    }
251
252    // Set additional fields
253    if let Some(fields) = args.get("fields").and_then(|v| v.as_object()) {
254        for (k, v) in fields {
255            template[k] = v.clone();
256        }
257    }
258
259    // Set collections
260    if let Some(colls) = args.get("collection_keys").and_then(|v| v.as_array()) {
261        template["collections"] = json!(colls);
262    }
263
264    // Set tags
265    if let Some(tags) = args.get("tags").and_then(|v| v.as_array()) {
266        let tag_objects: Vec<Value> = tags
267            .iter()
268            .filter_map(|t| t.as_str())
269            .map(|t| json!({"tag": t}))
270            .collect();
271        template["tags"] = json!(tag_objects);
272    }
273
274    match client.create_items(&[template]) {
275        Ok(result) => ToolCallResult::text(format!(
276            "Item created: {}",
277            serde_json::to_string_pretty(&result).unwrap_or_default()
278        )),
279        Err(e) => ToolCallResult::error(format!("Failed to create item: {e}")),
280    }
281}
282
283pub fn zotero_update_item(args: &Value, ctx: &ServerContext) -> ToolCallResult {
284    let client = match get_write_client(ctx) {
285        Ok(c) => c,
286        Err(e) => return ToolCallResult::error(e),
287    };
288
289    let citekey = match args.get("citekey").and_then(|v| v.as_str()) {
290        Some(ck) => ck,
291        None => return ToolCallResult::error("Missing parameter: citekey".into()),
292    };
293
294    let item_key = match resolve_citekey(ctx, citekey) {
295        Ok(k) => k,
296        Err(e) => return ToolCallResult::error(e),
297    };
298
299    // Fetch current item to get version
300    let item = match client.get_item(&item_key) {
301        Ok(i) => i,
302        Err(e) => return ToolCallResult::error(format!("Failed to fetch item: {e}")),
303    };
304    let version = item["version"].as_i64().unwrap_or(0) as i32;
305    let mut data = item["data"].clone();
306
307    // Apply field updates
308    if let Some(fields) = args.get("fields").and_then(|v| v.as_object()) {
309        for (k, v) in fields {
310            data[k] = v.clone();
311        }
312    }
313
314    // Apply tag updates
315    if let Some(tags) = args.get("tags").and_then(|v| v.as_array()) {
316        let tag_objects: Vec<Value> = tags
317            .iter()
318            .filter_map(|t| t.as_str())
319            .map(|t| json!({"tag": t}))
320            .collect();
321        data["tags"] = json!(tag_objects);
322    }
323
324    match client.update_item(&item_key, &data, version) {
325        Ok(()) => ToolCallResult::text(format!("Item {citekey} updated.")),
326        Err(e) => ToolCallResult::error(format!("Failed to update: {e}")),
327    }
328}
329
330pub fn zotero_add_tags(args: &Value, ctx: &ServerContext) -> ToolCallResult {
331    let client = match get_write_client(ctx) {
332        Ok(c) => c,
333        Err(e) => return ToolCallResult::error(e),
334    };
335
336    let citekey = match args.get("citekey").and_then(|v| v.as_str()) {
337        Some(ck) => ck,
338        None => return ToolCallResult::error("Missing parameter: citekey".into()),
339    };
340    let new_tags: Vec<&str> = match args.get("tags").and_then(|v| v.as_array()) {
341        Some(arr) => arr.iter().filter_map(|v| v.as_str()).collect(),
342        None => return ToolCallResult::error("Missing parameter: tags".into()),
343    };
344
345    let item_key = match resolve_citekey(ctx, citekey) {
346        Ok(k) => k,
347        Err(e) => return ToolCallResult::error(e),
348    };
349
350    let item = match client.get_item(&item_key) {
351        Ok(i) => i,
352        Err(e) => return ToolCallResult::error(format!("Failed to fetch item: {e}")),
353    };
354    let version = item["version"].as_i64().unwrap_or(0) as i32;
355    let mut data = item["data"].clone();
356
357    // Merge tags (preserve existing, add new)
358    let existing: std::collections::HashSet<String> = data["tags"]
359        .as_array()
360        .unwrap_or(&vec![])
361        .iter()
362        .filter_map(|t| t["tag"].as_str().map(String::from))
363        .collect();
364
365    let mut tags: Vec<Value> = existing.iter().map(|t| json!({"tag": t})).collect();
366    for tag in new_tags {
367        if !existing.contains(tag) {
368            tags.push(json!({"tag": tag}));
369        }
370    }
371    data["tags"] = json!(tags);
372
373    match client.update_item(&item_key, &data, version) {
374        Ok(()) => ToolCallResult::text(format!("Tags added to {citekey}.")),
375        Err(e) => ToolCallResult::error(format!("Failed to add tags: {e}")),
376    }
377}
378
379pub fn zotero_add_note(args: &Value, ctx: &ServerContext) -> ToolCallResult {
380    let client = match get_write_client(ctx) {
381        Ok(c) => c,
382        Err(e) => return ToolCallResult::error(e),
383    };
384
385    let citekey = match args.get("citekey").and_then(|v| v.as_str()) {
386        Some(ck) => ck,
387        None => return ToolCallResult::error("Missing parameter: citekey".into()),
388    };
389    let content = match args.get("content").and_then(|v| v.as_str()) {
390        Some(c) => c,
391        None => return ToolCallResult::error("Missing parameter: content".into()),
392    };
393
394    let item_key = match resolve_citekey(ctx, citekey) {
395        Ok(k) => k,
396        Err(e) => return ToolCallResult::error(e),
397    };
398
399    // Convert markdown to basic HTML (simple replacement)
400    let html = if content.contains('<') {
401        content.to_string() // Already HTML
402    } else {
403        format!("<p>{}</p>", content.replace('\n', "</p><p>"))
404    };
405
406    let note = json!({
407        "itemType": "note",
408        "parentItem": item_key,
409        "note": html,
410        "tags": args.get("tags").and_then(|v| v.as_array())
411            .map(|arr| arr.iter().filter_map(|t| t.as_str()).map(|t| json!({"tag": t})).collect::<Vec<_>>())
412            .unwrap_or_default(),
413    });
414
415    match client.create_items(&[note]) {
416        Ok(_) => ToolCallResult::text(format!("Note added to {citekey}.")),
417        Err(e) => ToolCallResult::error(format!("Failed to add note: {e}")),
418    }
419}
420
421pub fn zotero_create_collection(args: &Value, ctx: &ServerContext) -> ToolCallResult {
422    let client = match get_write_client(ctx) {
423        Ok(c) => c,
424        Err(e) => return ToolCallResult::error(e),
425    };
426
427    let name = match args.get("name").and_then(|v| v.as_str()) {
428        Some(n) => n,
429        None => return ToolCallResult::error("Missing parameter: name".into()),
430    };
431
432    // Check if collection already exists (via local SQLite)
433    if let Ok(zdb) = ctx.db.zotero()
434        && let Ok(colls) = zdb.collections()
435        && let Some(existing) = colls.iter().find(|c| c.name == name)
436    {
437        return ToolCallResult::text(format!(
438            "Collection '{}' already exists (key: {}).",
439            name, existing.key
440        ));
441    }
442
443    let mut coll = json!({"name": name});
444    if let Some(parent_key) = args.get("parent_key").and_then(|v| v.as_str()) {
445        coll["parentCollection"] = json!(parent_key);
446    }
447
448    match client.create_collections(&[coll]) {
449        Ok(result) => ToolCallResult::text(format!(
450            "Collection '{}' created: {}",
451            name,
452            serde_json::to_string_pretty(&result).unwrap_or_default()
453        )),
454        Err(e) => ToolCallResult::error(format!("Failed to create collection: {e}")),
455    }
456}
457
458pub fn zotero_add_to_collection(args: &Value, ctx: &ServerContext) -> ToolCallResult {
459    let client = match get_write_client(ctx) {
460        Ok(c) => c,
461        Err(e) => return ToolCallResult::error(e),
462    };
463
464    let collection_key = match args.get("collection_key").and_then(|v| v.as_str()) {
465        Some(k) => k,
466        None => return ToolCallResult::error("Missing parameter: collection_key".into()),
467    };
468
469    // Accept either citekey or item_key
470    let item_key = if let Some(ck) = args.get("citekey").and_then(|v| v.as_str()) {
471        match resolve_citekey(ctx, ck) {
472            Ok(k) => k,
473            Err(e) => return ToolCallResult::error(e),
474        }
475    } else if let Some(ik) = args.get("item_key").and_then(|v| v.as_str()) {
476        ik.to_string()
477    } else {
478        return ToolCallResult::error("Missing parameter: citekey or item_key".into());
479    };
480
481    let item = match client.get_item(&item_key) {
482        Ok(i) => i,
483        Err(e) => return ToolCallResult::error(format!("Failed to fetch item: {e}")),
484    };
485    let version = item["version"].as_i64().unwrap_or(0) as i32;
486    let mut data = item["data"].clone();
487
488    // Add collection if not already present
489    let mut collections: Vec<String> = data["collections"]
490        .as_array()
491        .unwrap_or(&vec![])
492        .iter()
493        .filter_map(|c| c.as_str().map(String::from))
494        .collect();
495
496    if !collections.contains(&collection_key.to_string()) {
497        collections.push(collection_key.to_string());
498        data["collections"] = json!(collections);
499        match client.update_item(&item_key, &data, version) {
500            Ok(()) => ToolCallResult::text(format!(
501                "Item {item_key} added to collection {collection_key}."
502            )),
503            Err(e) => ToolCallResult::error(format!("Failed: {e}")),
504        }
505    } else {
506        ToolCallResult::text(format!(
507            "Item {item_key} already in collection {collection_key}."
508        ))
509    }
510}
511
512pub fn zotero_remove_from_collection(args: &Value, ctx: &ServerContext) -> ToolCallResult {
513    let client = match get_write_client(ctx) {
514        Ok(c) => c,
515        Err(e) => return ToolCallResult::error(e),
516    };
517
518    let collection_key = match args.get("collection_key").and_then(|v| v.as_str()) {
519        Some(k) => k,
520        None => return ToolCallResult::error("Missing parameter: collection_key".into()),
521    };
522
523    let item_key = if let Some(ck) = args.get("citekey").and_then(|v| v.as_str()) {
524        match resolve_citekey(ctx, ck) {
525            Ok(k) => k,
526            Err(e) => return ToolCallResult::error(e),
527        }
528    } else if let Some(ik) = args.get("item_key").and_then(|v| v.as_str()) {
529        ik.to_string()
530    } else {
531        return ToolCallResult::error("Missing parameter: citekey or item_key".into());
532    };
533
534    let item = match client.get_item(&item_key) {
535        Ok(i) => i,
536        Err(e) => return ToolCallResult::error(format!("Failed to fetch item: {e}")),
537    };
538    let version = item["version"].as_i64().unwrap_or(0) as i32;
539    let mut data = item["data"].clone();
540
541    let collections: Vec<String> = data["collections"]
542        .as_array()
543        .unwrap_or(&vec![])
544        .iter()
545        .filter_map(|c| c.as_str().map(String::from))
546        .filter(|c| c != collection_key)
547        .collect();
548
549    data["collections"] = json!(collections);
550    match client.update_item(&item_key, &data, version) {
551        Ok(()) => ToolCallResult::text(format!("Item removed from collection {collection_key}.")),
552        Err(e) => ToolCallResult::error(format!("Failed: {e}")),
553    }
554}
555
556pub fn zotero_delete_item(args: &Value, ctx: &ServerContext) -> ToolCallResult {
557    let client = match get_write_client(ctx) {
558        Ok(c) => c,
559        Err(e) => return ToolCallResult::error(e),
560    };
561
562    let item_key = if let Some(ck) = args.get("citekey").and_then(|v| v.as_str()) {
563        match resolve_citekey(ctx, ck) {
564            Ok(k) => k,
565            Err(e) => return ToolCallResult::error(e),
566        }
567    } else if let Some(ik) = args.get("item_key").and_then(|v| v.as_str()) {
568        ik.to_string()
569    } else {
570        return ToolCallResult::error("Missing parameter: citekey or item_key".into());
571    };
572
573    let item = match client.get_item(&item_key) {
574        Ok(i) => i,
575        Err(e) => return ToolCallResult::error(format!("Failed to fetch item: {e}")),
576    };
577    let version = item["version"].as_i64().unwrap_or(0) as i32;
578
579    match client.delete_item(&item_key, version) {
580        Ok(()) => ToolCallResult::text(format!("Item {item_key} deleted permanently.")),
581        Err(e) => ToolCallResult::error(format!("Failed to delete: {e}")),
582    }
583}
584
585pub fn zotero_merge_items(args: &Value, ctx: &ServerContext) -> ToolCallResult {
586    let client = match get_write_client(ctx) {
587        Ok(c) => c,
588        Err(e) => return ToolCallResult::error(e),
589    };
590
591    let keep_ck = match args.get("keep_citekey").and_then(|v| v.as_str()) {
592        Some(ck) => ck,
593        None => return ToolCallResult::error("Missing parameter: keep_citekey".into()),
594    };
595    let delete_ck = match args.get("delete_citekey").and_then(|v| v.as_str()) {
596        Some(ck) => ck,
597        None => return ToolCallResult::error("Missing parameter: delete_citekey".into()),
598    };
599
600    let keep_key = match resolve_citekey(ctx, keep_ck) {
601        Ok(k) => k,
602        Err(e) => return ToolCallResult::error(e),
603    };
604    let delete_key = match resolve_citekey(ctx, delete_ck) {
605        Ok(k) => k,
606        Err(e) => return ToolCallResult::error(e),
607    };
608
609    // Fetch both items
610    let keep_item = match client.get_item(&keep_key) {
611        Ok(i) => i,
612        Err(e) => return ToolCallResult::error(format!("Failed to fetch keep item: {e}")),
613    };
614    let delete_item = match client.get_item(&delete_key) {
615        Ok(i) => i,
616        Err(e) => return ToolCallResult::error(format!("Failed to fetch delete item: {e}")),
617    };
618
619    let keep_version = keep_item["version"].as_i64().unwrap_or(0) as i32;
620    let delete_version = delete_item["version"].as_i64().unwrap_or(0) as i32;
621    let mut keep_data = keep_item["data"].clone();
622
623    // Merge tags
624    let mut tags: std::collections::HashSet<String> = keep_data["tags"]
625        .as_array()
626        .unwrap_or(&vec![])
627        .iter()
628        .filter_map(|t| t["tag"].as_str().map(String::from))
629        .collect();
630    if let Some(delete_tags) = delete_item["data"]["tags"].as_array() {
631        for t in delete_tags {
632            if let Some(tag) = t["tag"].as_str() {
633                tags.insert(tag.to_string());
634            }
635        }
636    }
637    keep_data["tags"] = json!(tags.iter().map(|t| json!({"tag": t})).collect::<Vec<_>>());
638
639    // Merge collections
640    let mut colls: std::collections::HashSet<String> = keep_data["collections"]
641        .as_array()
642        .unwrap_or(&vec![])
643        .iter()
644        .filter_map(|c| c.as_str().map(String::from))
645        .collect();
646    if let Some(delete_colls) = delete_item["data"]["collections"].as_array() {
647        for c in delete_colls {
648            if let Some(coll) = c.as_str() {
649                colls.insert(coll.to_string());
650            }
651        }
652    }
653    keep_data["collections"] = json!(colls.into_iter().collect::<Vec<_>>());
654
655    // Update keep item
656    if let Err(e) = client.update_item(&keep_key, &keep_data, keep_version) {
657        return ToolCallResult::error(format!("Failed to update keep item: {e}"));
658    }
659
660    // Delete the duplicate
661    if let Err(e) = client.delete_item(&delete_key, delete_version) {
662        return ToolCallResult::error(format!("Merged but failed to delete duplicate: {e}"));
663    }
664
665    ToolCallResult::text(format!(
666        "Merged {delete_ck} into {keep_ck}. Deleted {delete_ck}."
667    ))
668}
669
670pub fn zotero_attach_pdf(args: &Value, ctx: &ServerContext) -> ToolCallResult {
671    let client = match get_write_client(ctx) {
672        Ok(c) => c,
673        Err(e) => return ToolCallResult::error(e),
674    };
675
676    let raw_key = match args.get("item_key").and_then(|v| v.as_str()) {
677        Some(k) => k,
678        None => return ToolCallResult::error("Missing parameter: item_key".into()),
679    };
680    let pdf_url = match args.get("pdf_url").and_then(|v| v.as_str()) {
681        Some(u) => u,
682        None => return ToolCallResult::error("Missing parameter: pdf_url".into()),
683    };
684    let title = args.get("title").and_then(|v| v.as_str());
685
686    // Resolve citekey → item_key if needed
687    let item_key = match resolve_item_key(ctx, raw_key) {
688        Ok(k) => k,
689        Err(e) => return ToolCallResult::error(format!("Cannot resolve '{raw_key}': {e}")),
690    };
691
692    // Check if item already has a PDF
693    if let Ok(zdb) = ctx.db.zotero()
694        && let Ok(Some(item)) = zdb.item_by_key(&item_key)
695        && let Ok(atts) = zdb.item_attachments(item.item_id)
696        && atts.iter().any(|a| a.content_type == "application/pdf")
697    {
698        return ToolCallResult::text(format!("Item {item_key} already has a PDF attachment."));
699    }
700
701    // Validate item_key is alphanumeric (prevent path traversal)
702    if !item_key.chars().all(|c| c.is_ascii_alphanumeric()) {
703        return ToolCallResult::error("Invalid item_key: must be alphanumeric".into());
704    }
705
706    // Download PDF to temp file
707    let tmp_dir = std::env::temp_dir();
708    let tmp_file = tmp_dir.join(format!("biblion-{item_key}.pdf"));
709    if let Err(e) = client.download_file(pdf_url, &tmp_file) {
710        return ToolCallResult::error(format!("Download failed: {e}"));
711    }
712
713    let display_title = title.unwrap_or("PDF");
714    match client.attach_file(&item_key, &tmp_file, display_title) {
715        Ok(_) => {
716            let _ = std::fs::remove_file(&tmp_file);
717            ToolCallResult::text(format!("PDF attached to {item_key}."))
718        }
719        Err(e) => {
720            let _ = std::fs::remove_file(&tmp_file);
721            ToolCallResult::error(format!("Failed to attach PDF: {e}"))
722        }
723    }
724}
725
726/// Scan items for missing PDFs and resolve from 9 open-access sources.
727pub fn zotero_fetch_missing_pdfs(args: &Value, ctx: &ServerContext) -> ToolCallResult {
728    let dry_run = args
729        .get("dry_run")
730        .and_then(|v| v.as_bool())
731        .unwrap_or(false);
732    let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
733    let collection_key = args.get("collection_key").and_then(|v| v.as_str());
734
735    let zdb = match ctx.db.zotero() {
736        Ok(db) => db,
737        Err(e) => return ToolCallResult::error(e.to_string()),
738    };
739
740    // Get items that need PDFs
741    let items_to_scan: Vec<(i64, String)> = if let Some(ck) = collection_key {
742        match zdb.collection_items(ck, limit) {
743            Ok(items) => items,
744            Err(e) => return ToolCallResult::error(e.to_string()),
745        }
746    } else {
747        match zdb.recent_items(limit) {
748            Ok(items) => items,
749            Err(e) => return ToolCallResult::error(e.to_string()),
750        }
751    };
752
753    // Filter to items without PDF attachments
754    let mut missing: Vec<(String, Option<String>, Option<String>)> = Vec::new(); // (item_key, doi, title)
755    for (item_id, item_key) in &items_to_scan {
756        let has_pdf = zdb
757            .item_attachments(*item_id)
758            .map(|atts| atts.iter().any(|a| a.content_type == "application/pdf"))
759            .unwrap_or(false);
760
761        if !has_pdf {
762            let metadata = zdb.item_metadata(*item_id).unwrap_or_default();
763            missing.push((
764                item_key.clone(),
765                metadata.get("DOI").cloned(),
766                metadata.get("title").cloned(),
767            ));
768        }
769    }
770
771    if missing.is_empty() {
772        return ToolCallResult::text(format!(
773            "Scanned {} items. All have PDF attachments.",
774            items_to_scan.len()
775        ));
776    }
777
778    let mut output = format!(
779        "Scanned {} items, {} missing PDFs.\n\n",
780        items_to_scan.len(),
781        missing.len()
782    );
783
784    let mut resolved = 0;
785    let mut attached = 0;
786
787    for (item_key, doi, title) in &missing {
788        let result = paper_resolver::resolve_pdf_with_config(
789            doi.as_deref(),
790            None,
791            title.as_deref(),
792            &ctx.config.resolver,
793        );
794
795        let citekey = ctx
796            .db
797            .citekey_for_item_key(item_key)
798            .unwrap_or_else(|| item_key.clone());
799
800        match result {
801            Some(pdf) if pdf.downloadable => {
802                resolved += 1;
803                if dry_run {
804                    output.push_str(&format!(
805                        "[would attach] {citekey} — {} (via {})\n",
806                        pdf.url, pdf.source
807                    ));
808                } else {
809                    // Actually download and attach
810                    let client = match get_write_client(ctx) {
811                        Ok(c) => c,
812                        Err(e) => {
813                            output.push_str(&format!("[error] {citekey} — {e}\n"));
814                            continue;
815                        }
816                    };
817                    let tmp = std::env::temp_dir().join(format!("biblion-{item_key}.pdf"));
818                    match client.download_file(&pdf.url, &tmp) {
819                        Ok(()) => {
820                            match client.attach_file(item_key, &tmp, &citekey) {
821                                Ok(_) => {
822                                    attached += 1;
823                                    output.push_str(&format!(
824                                        "[attached] {citekey} — {} (via {})\n",
825                                        pdf.url, pdf.source
826                                    ));
827                                }
828                                Err(e) => {
829                                    output.push_str(&format!(
830                                        "[error] {citekey} — attach failed: {e}\n"
831                                    ));
832                                }
833                            }
834                            let _ = std::fs::remove_file(&tmp);
835                        }
836                        Err(e) => {
837                            output.push_str(&format!("[error] {citekey} — download failed: {e}\n"));
838                        }
839                    }
840                }
841            }
842            Some(pdf) => {
843                output.push_str(&format!(
844                    "[manual] {citekey} — {} (via {}, not downloadable)\n",
845                    pdf.url, pdf.source
846                ));
847            }
848            None => {
849                output.push_str(&format!("[not found] {citekey}\n"));
850            }
851        }
852    }
853
854    let not_found = missing.len() - resolved - attached;
855    let manual = resolved - attached;
856    output.push_str(&format!(
857        "\nResolved: {resolved}, Attached: {attached}, Manual: {manual}, Not found: {not_found}"
858    ));
859
860    ToolCallResult::text(output)
861}
862
863#[cfg(test)]
864mod tests {
865    use super::*;
866    use crate::config::{Config, LogLevel};
867    use crate::db::DbPool;
868    use crate::test_helpers::test_zotero_db;
869    use serde_json::json;
870    use wiremock::matchers::{method, path};
871    use wiremock::{Mock, MockServer, ResponseTemplate};
872
873    /// Create a test context with writes enabled and a custom API base URL.
874    fn write_ctx_with_base_url(base_url: &str) -> ServerContext {
875        let zdb = test_zotero_db();
876        ServerContext {
877            db: DbPool {
878                zotero: Some(zdb),
879                bbt: None,
880            },
881            config: Config {
882                zotero_sqlite_path: "/tmp/test.sqlite".into(),
883                zotero_storage_path: "/tmp/storage".into(),
884                bbt_migrated_path: "/tmp/bbt".into(),
885                zotero_api_key: Some("test-api-key".into()),
886                zotero_library_id: "12345".into(),
887                zotero_library_type: "user".into(),
888                bbt_url: "http://localhost:23119".into(),
889                log_level: LogLevel::Quiet,
890                writes_enabled: true,
891                resolver: paper_resolver::ResolverConfig::default(),
892                zotero_api_base_url: Some(base_url.into()),
893            },
894        }
895    }
896
897    /// Helper: spin up a tokio runtime and a wiremock MockServer.
898    fn start_mock() -> (tokio::runtime::Runtime, MockServer) {
899        let rt = tokio::runtime::Runtime::new().unwrap();
900        let server = rt.block_on(MockServer::start());
901        (rt, server)
902    }
903
904    /// Standard mock response for GET /items/{key}.
905    fn item_response(key: &str, version: i64) -> ResponseTemplate {
906        ResponseTemplate::new(200).set_body_json(json!({
907            "key": key,
908            "version": version,
909            "data": {
910                "key": key,
911                "version": version,
912                "itemType": "journalArticle",
913                "title": "Hints on Test Data Selection",
914                "tags": [{"tag": "mutation-testing"}, {"tag": "foundational"}],
915                "collections": ["COL00001"],
916                "creators": [],
917            }
918        }))
919    }
920
921    // -----------------------------------------------------------------------
922    // zotero_create_item — success
923    // -----------------------------------------------------------------------
924
925    #[test]
926    fn create_item_success() {
927        let (rt, mock_server) = start_mock();
928
929        rt.block_on(async {
930            Mock::given(method("GET"))
931                .and(path("/items/new"))
932                .respond_with(ResponseTemplate::new(200).set_body_json(json!({
933                    "itemType": "journalArticle",
934                    "title": "",
935                    "creators": [],
936                    "tags": [],
937                    "collections": [],
938                })))
939                .mount(&mock_server)
940                .await;
941
942            Mock::given(method("POST"))
943                .and(path("/items"))
944                .respond_with(ResponseTemplate::new(200).set_body_json(json!({
945                    "successful": {"0": {"key": "NEWKEY01", "version": 1}},
946                    "unchanged": {},
947                    "failed": {}
948                })))
949                .mount(&mock_server)
950                .await;
951        });
952
953        let ctx = write_ctx_with_base_url(&mock_server.uri());
954        let args = json!({
955            "item_type": "journalArticle",
956            "title": "Test Paper",
957        });
958        let result = zotero_create_item(&args, &ctx);
959        assert!(result.is_error.is_none());
960        assert!(result.content[0].text.contains("NEWKEY01"));
961    }
962
963    // -----------------------------------------------------------------------
964    // zotero_create_item — missing params
965    // -----------------------------------------------------------------------
966
967    #[test]
968    fn create_item_missing_title() {
969        let (rt, mock_server) = start_mock();
970        let _ = rt;
971        let ctx = write_ctx_with_base_url(&mock_server.uri());
972        let args = json!({"item_type": "journalArticle"});
973        let result = zotero_create_item(&args, &ctx);
974        assert_eq!(result.is_error, Some(true));
975        assert!(result.content[0].text.contains("Missing parameter: title"));
976    }
977
978    #[test]
979    fn create_item_missing_item_type() {
980        let (rt, mock_server) = start_mock();
981        let _ = rt;
982        let ctx = write_ctx_with_base_url(&mock_server.uri());
983        let args = json!({"title": "Test"});
984        let result = zotero_create_item(&args, &ctx);
985        assert_eq!(result.is_error, Some(true));
986        assert!(
987            result.content[0]
988                .text
989                .contains("Missing parameter: item_type")
990        );
991    }
992
993    // -----------------------------------------------------------------------
994    // zotero_update_item — success (resolves citekey via SQLite)
995    // -----------------------------------------------------------------------
996
997    #[test]
998    fn update_item_success() {
999        let (rt, mock_server) = start_mock();
1000
1001        rt.block_on(async {
1002            Mock::given(method("GET"))
1003                .and(path("/items/ABC12345"))
1004                .respond_with(item_response("ABC12345", 5))
1005                .mount(&mock_server)
1006                .await;
1007
1008            Mock::given(method("PATCH"))
1009                .and(path("/items/ABC12345"))
1010                .respond_with(ResponseTemplate::new(204))
1011                .mount(&mock_server)
1012                .await;
1013        });
1014
1015        let ctx = write_ctx_with_base_url(&mock_server.uri());
1016        let args = json!({
1017            "citekey": "demilloHintsTestData1978",
1018            "fields": {"title": "Updated Title"},
1019        });
1020        let result = zotero_update_item(&args, &ctx);
1021        assert!(result.is_error.is_none());
1022        assert!(result.content[0].text.contains("updated"));
1023    }
1024
1025    // -----------------------------------------------------------------------
1026    // zotero_delete_item — success
1027    // -----------------------------------------------------------------------
1028
1029    #[test]
1030    fn delete_item_success() {
1031        let (rt, mock_server) = start_mock();
1032
1033        rt.block_on(async {
1034            Mock::given(method("GET"))
1035                .and(path("/items/ABC12345"))
1036                .respond_with(item_response("ABC12345", 5))
1037                .mount(&mock_server)
1038                .await;
1039
1040            Mock::given(method("DELETE"))
1041                .and(path("/items/ABC12345"))
1042                .respond_with(ResponseTemplate::new(204))
1043                .mount(&mock_server)
1044                .await;
1045        });
1046
1047        let ctx = write_ctx_with_base_url(&mock_server.uri());
1048        let args = json!({"citekey": "demilloHintsTestData1978"});
1049        let result = zotero_delete_item(&args, &ctx);
1050        assert!(result.is_error.is_none());
1051        assert!(result.content[0].text.contains("deleted"));
1052    }
1053
1054    // -----------------------------------------------------------------------
1055    // zotero_add_tags — success (merges with existing)
1056    // -----------------------------------------------------------------------
1057
1058    #[test]
1059    fn add_tags_success() {
1060        let (rt, mock_server) = start_mock();
1061
1062        rt.block_on(async {
1063            Mock::given(method("GET"))
1064                .and(path("/items/ABC12345"))
1065                .respond_with(item_response("ABC12345", 5))
1066                .mount(&mock_server)
1067                .await;
1068
1069            Mock::given(method("PATCH"))
1070                .and(path("/items/ABC12345"))
1071                .respond_with(ResponseTemplate::new(204))
1072                .mount(&mock_server)
1073                .await;
1074        });
1075
1076        let ctx = write_ctx_with_base_url(&mock_server.uri());
1077        let args = json!({
1078            "citekey": "demilloHintsTestData1978",
1079            "tags": ["new-tag", "mutation-testing"],
1080        });
1081        let result = zotero_add_tags(&args, &ctx);
1082        assert!(result.is_error.is_none());
1083        assert!(result.content[0].text.contains("Tags added"));
1084    }
1085
1086    // -----------------------------------------------------------------------
1087    // zotero_add_note — success
1088    // -----------------------------------------------------------------------
1089
1090    #[test]
1091    fn add_note_success() {
1092        let (rt, mock_server) = start_mock();
1093
1094        rt.block_on(async {
1095            Mock::given(method("POST"))
1096                .and(path("/items"))
1097                .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1098                    "successful": {"0": {"key": "NOTE0002", "version": 1}},
1099                    "unchanged": {},
1100                    "failed": {}
1101                })))
1102                .mount(&mock_server)
1103                .await;
1104        });
1105
1106        let ctx = write_ctx_with_base_url(&mock_server.uri());
1107        let args = json!({
1108            "citekey": "demilloHintsTestData1978",
1109            "content": "This is a test note.",
1110        });
1111        let result = zotero_add_note(&args, &ctx);
1112        assert!(result.is_error.is_none());
1113        assert!(result.content[0].text.contains("Note added"));
1114    }
1115
1116    // -----------------------------------------------------------------------
1117    // zotero_create_collection — success
1118    // -----------------------------------------------------------------------
1119
1120    #[test]
1121    fn create_collection_success() {
1122        let (rt, mock_server) = start_mock();
1123
1124        rt.block_on(async {
1125            Mock::given(method("POST"))
1126                .and(path("/collections"))
1127                .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1128                    "successful": {"0": {"key": "NEWCOL01", "version": 1}},
1129                    "unchanged": {},
1130                    "failed": {}
1131                })))
1132                .mount(&mock_server)
1133                .await;
1134        });
1135
1136        let ctx = write_ctx_with_base_url(&mock_server.uri());
1137        let args = json!({"name": "New Collection"});
1138        let result = zotero_create_collection(&args, &ctx);
1139        assert!(result.is_error.is_none());
1140        assert!(result.content[0].text.contains("created"));
1141    }
1142
1143    // -----------------------------------------------------------------------
1144    // zotero_create_collection — already exists
1145    // -----------------------------------------------------------------------
1146
1147    #[test]
1148    fn create_collection_already_exists() {
1149        let (rt, mock_server) = start_mock();
1150        let _ = rt;
1151
1152        let ctx = write_ctx_with_base_url(&mock_server.uri());
1153        let args = json!({"name": "Mutation Testing"});
1154        let result = zotero_create_collection(&args, &ctx);
1155        assert!(result.is_error.is_none());
1156        assert!(result.content[0].text.contains("already exists"));
1157        assert!(result.content[0].text.contains("COL00001"));
1158    }
1159
1160    // -----------------------------------------------------------------------
1161    // zotero_add_to_collection — success
1162    // -----------------------------------------------------------------------
1163
1164    #[test]
1165    fn add_to_collection_success() {
1166        let (rt, mock_server) = start_mock();
1167
1168        rt.block_on(async {
1169            Mock::given(method("GET"))
1170                .and(path("/items/ABC12345"))
1171                .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1172                    "key": "ABC12345",
1173                    "version": 5,
1174                    "data": {
1175                        "key": "ABC12345",
1176                        "version": 5,
1177                        "itemType": "journalArticle",
1178                        "title": "Test",
1179                        "tags": [],
1180                        "collections": [],
1181                        "creators": [],
1182                    }
1183                })))
1184                .mount(&mock_server)
1185                .await;
1186
1187            Mock::given(method("PATCH"))
1188                .and(path("/items/ABC12345"))
1189                .respond_with(ResponseTemplate::new(204))
1190                .mount(&mock_server)
1191                .await;
1192        });
1193
1194        let ctx = write_ctx_with_base_url(&mock_server.uri());
1195        let args = json!({
1196            "citekey": "demilloHintsTestData1978",
1197            "collection_key": "NEWCOL01",
1198        });
1199        let result = zotero_add_to_collection(&args, &ctx);
1200        assert!(result.is_error.is_none());
1201        assert!(result.content[0].text.contains("added to collection"));
1202    }
1203
1204    // -----------------------------------------------------------------------
1205    // zotero_add_to_collection — already in collection
1206    // -----------------------------------------------------------------------
1207
1208    #[test]
1209    fn add_to_collection_already_member() {
1210        let (rt, mock_server) = start_mock();
1211
1212        rt.block_on(async {
1213            Mock::given(method("GET"))
1214                .and(path("/items/ABC12345"))
1215                .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1216                    "key": "ABC12345",
1217                    "version": 5,
1218                    "data": {
1219                        "key": "ABC12345",
1220                        "version": 5,
1221                        "itemType": "journalArticle",
1222                        "title": "Test",
1223                        "tags": [],
1224                        "collections": ["COL00001"],
1225                        "creators": [],
1226                    }
1227                })))
1228                .mount(&mock_server)
1229                .await;
1230        });
1231
1232        let ctx = write_ctx_with_base_url(&mock_server.uri());
1233        let args = json!({
1234            "citekey": "demilloHintsTestData1978",
1235            "collection_key": "COL00001",
1236        });
1237        let result = zotero_add_to_collection(&args, &ctx);
1238        assert!(result.is_error.is_none());
1239        assert!(result.content[0].text.contains("already in collection"));
1240    }
1241
1242    // -----------------------------------------------------------------------
1243    // zotero_remove_from_collection — success
1244    // -----------------------------------------------------------------------
1245
1246    #[test]
1247    fn remove_from_collection_success() {
1248        let (rt, mock_server) = start_mock();
1249
1250        rt.block_on(async {
1251            Mock::given(method("GET"))
1252                .and(path("/items/ABC12345"))
1253                .respond_with(item_response("ABC12345", 5))
1254                .mount(&mock_server)
1255                .await;
1256
1257            Mock::given(method("PATCH"))
1258                .and(path("/items/ABC12345"))
1259                .respond_with(ResponseTemplate::new(204))
1260                .mount(&mock_server)
1261                .await;
1262        });
1263
1264        let ctx = write_ctx_with_base_url(&mock_server.uri());
1265        let args = json!({
1266            "citekey": "demilloHintsTestData1978",
1267            "collection_key": "COL00001",
1268        });
1269        let result = zotero_remove_from_collection(&args, &ctx);
1270        assert!(result.is_error.is_none());
1271        assert!(result.content[0].text.contains("removed from collection"));
1272    }
1273
1274    // -----------------------------------------------------------------------
1275    // zotero_merge_items — success
1276    // -----------------------------------------------------------------------
1277
1278    #[test]
1279    fn merge_items_success() {
1280        let (rt, mock_server) = start_mock();
1281
1282        rt.block_on(async {
1283            Mock::given(method("GET"))
1284                .and(path("/items/ABC12345"))
1285                .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1286                    "key": "ABC12345",
1287                    "version": 5,
1288                    "data": {
1289                        "key": "ABC12345",
1290                        "itemType": "journalArticle",
1291                        "title": "Hints on Test Data Selection",
1292                        "tags": [{"tag": "mutation-testing"}],
1293                        "collections": ["COL00001"],
1294                    }
1295                })))
1296                .mount(&mock_server)
1297                .await;
1298
1299            Mock::given(method("GET"))
1300                .and(path("/items/DEF67890"))
1301                .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1302                    "key": "DEF67890",
1303                    "version": 3,
1304                    "data": {
1305                        "key": "DEF67890",
1306                        "itemType": "book",
1307                        "title": "The Art of Testing",
1308                        "tags": [{"tag": "foundational"}, {"tag": "testing"}],
1309                        "collections": ["COL00002"],
1310                    }
1311                })))
1312                .mount(&mock_server)
1313                .await;
1314
1315            Mock::given(method("PATCH"))
1316                .and(path("/items/ABC12345"))
1317                .respond_with(ResponseTemplate::new(204))
1318                .mount(&mock_server)
1319                .await;
1320
1321            Mock::given(method("DELETE"))
1322                .and(path("/items/DEF67890"))
1323                .respond_with(ResponseTemplate::new(204))
1324                .mount(&mock_server)
1325                .await;
1326        });
1327
1328        let ctx = write_ctx_with_base_url(&mock_server.uri());
1329        let args = json!({
1330            "keep_citekey": "demilloHintsTestData1978",
1331            "delete_citekey": "artTesting2020",
1332        });
1333        let result = zotero_merge_items(&args, &ctx);
1334        assert!(result.is_error.is_none());
1335        assert!(result.content[0].text.contains("Merged"));
1336        assert!(result.content[0].text.contains("Deleted"));
1337    }
1338
1339    // -----------------------------------------------------------------------
1340    // writes_disabled — get_write_client returns error
1341    // -----------------------------------------------------------------------
1342
1343    #[test]
1344    fn writes_disabled_returns_error() {
1345        let ctx = crate::test_helpers::test_ctx();
1346        let result = get_write_client(&ctx);
1347        assert!(result.is_err());
1348        assert!(result.unwrap_err().contains("Write tools disabled"));
1349    }
1350
1351    // -----------------------------------------------------------------------
1352    // missing_api_key — get_write_client returns error
1353    // -----------------------------------------------------------------------
1354
1355    #[test]
1356    fn missing_api_key_returns_error() {
1357        let zdb = test_zotero_db();
1358        let ctx = ServerContext {
1359            db: DbPool {
1360                zotero: Some(zdb),
1361                bbt: None,
1362            },
1363            config: Config {
1364                zotero_sqlite_path: "/tmp/test.sqlite".into(),
1365                zotero_storage_path: "/tmp/storage".into(),
1366                bbt_migrated_path: "/tmp/bbt".into(),
1367                zotero_api_key: None,
1368                zotero_library_id: "12345".into(),
1369                zotero_library_type: "user".into(),
1370                bbt_url: "http://localhost:23119".into(),
1371                log_level: LogLevel::Quiet,
1372                writes_enabled: true,
1373                resolver: paper_resolver::ResolverConfig::default(),
1374                zotero_api_base_url: None,
1375            },
1376        };
1377        let result = get_write_client(&ctx);
1378        assert!(result.is_err());
1379        assert!(result.unwrap_err().contains("ZOTERO_API_KEY"));
1380    }
1381
1382    /// Regression: 8-char alphanumeric keys must be accepted as Zotero item keys
1383    /// without any SQLite lookup (freshly created items aren't in local DB).
1384    #[test]
1385    fn resolve_item_key_accepts_8char_alphanumeric() {
1386        let ctx = write_ctx_with_base_url("http://unused");
1387        // "TQPUXSC2" is NOT in the test DB, but should be accepted by format
1388        let result = resolve_item_key(&ctx, "TQPUXSC2");
1389        assert_eq!(result, Ok("TQPUXSC2".into()));
1390    }
1391
1392    #[test]
1393    fn resolve_item_key_accepts_mixed_case_digits() {
1394        let ctx = write_ctx_with_base_url("http://unused");
1395        let result = resolve_item_key(&ctx, "Ab3Cd4Ef");
1396        assert_eq!(result, Ok("Ab3Cd4Ef".into()));
1397    }
1398
1399    #[test]
1400    fn resolve_item_key_short_key_falls_through_to_citekey() {
1401        let ctx = write_ctx_with_base_url("http://unused");
1402        // 3 chars — not an item key, should try citekey resolution (which fails)
1403        let result = resolve_item_key(&ctx, "ABC");
1404        assert!(result.is_err());
1405    }
1406
1407    #[test]
1408    fn resolve_item_key_long_string_treated_as_citekey() {
1409        let ctx = write_ctx_with_base_url("http://unused");
1410        // Long string — clearly a citekey, not an item key
1411        let result = resolve_item_key(&ctx, "jiaAnalysisSurveyDevelopment2011");
1412        // Fails because no BBT DB in test context
1413        assert!(result.is_err());
1414    }
1415}