biblion/db/
zotero.rs

1//! Zotero SQLite database reader.
2//!
3//! # Zotero's EAV schema
4//!
5//! Zotero uses an Entity-Attribute-Value pattern for item metadata:
6//!
7//! ```text
8//! items (itemID, key, itemTypeID, dateAdded, dateModified)
9//!   └── itemData (itemID, fieldID, valueID)
10//!         └── itemDataValues (valueID, value)
11//!               └── fields (fieldID, fieldName)
12//! ```
13//!
14//! This means getting an item's title requires a 3-table JOIN:
15//! `items → itemData → itemDataValues`, filtered by `fieldID` for "title".
16//!
17//! Similarly, creators, tags, and attachments are stored in separate tables
18//! linked by `itemID`.
19//!
20//! # Performance
21//!
22//! The database is ~71MB with ~2700 items. All queries use prepared statements
23//! (`prepare_cached`) and the entire database fits in the OS page cache after
24//! first access. Expected latency: <1ms for single-item lookups, <10ms for
25//! full-table scans (search).
26//!
27//! # Filtering conventions
28//!
29//! - Always exclude deleted items: `itemID NOT IN (SELECT itemID FROM deletedItems)`
30//! - Always filter to personal library: `libraryID = 1`
31//! - Exclude attachments and notes when listing "real" items:
32//!   `itemTypeID NOT IN (SELECT itemTypeID FROM itemTypes WHERE typeName IN ('attachment', 'note', 'annotation'))`
33
34use std::collections::HashMap;
35use std::path::Path;
36
37use anyhow::{Context, Result};
38use rusqlite::Connection;
39
40/// A Zotero library item with all its metadata assembled from the EAV schema.
41#[derive(Debug, Clone)]
42pub struct ZoteroItem {
43    pub item_id: i64,
44    pub item_key: String,
45    pub item_type: String,
46    pub title: String,
47    pub date: Option<String>,
48    pub doi: Option<String>,
49    pub url: Option<String>,
50    pub abstract_note: Option<String>,
51    pub creators: Vec<Creator>,
52    pub tags: Vec<String>,
53    pub date_added: String,
54    pub date_modified: String,
55}
56
57#[derive(Debug, Clone)]
58pub struct Creator {
59    pub creator_type: String,
60    pub first_name: Option<String>,
61    pub last_name: String,
62    pub order: i32,
63}
64
65#[derive(Debug, Clone)]
66pub struct Collection {
67    pub collection_id: i64,
68    pub key: String,
69    pub name: String,
70    pub parent_key: Option<String>,
71}
72
73#[derive(Debug, Clone)]
74pub struct Attachment {
75    pub item_key: String,
76    pub content_type: String,
77    pub path: Option<String>,
78    pub title: Option<String>,
79    /// MD5 content hash computed by Zotero on import.
80    /// Exposes a content-identity primitive for external tools
81    /// (deduplication, verification, linking) without Biblion
82    /// needing to know about consumers.
83    pub storage_hash: Option<String>,
84}
85
86/// Read-only connection to Zotero's main SQLite database.
87pub struct ZoteroDb {
88    conn: Connection,
89}
90
91impl ZoteroDb {
92    /// Access the underlying SQLite connection (for cross-database queries).
93    pub fn conn(&self) -> &Connection {
94        &self.conn
95    }
96
97    /// Create from an existing connection (for testing with in-memory DBs).
98    #[cfg(test)]
99    pub(crate) fn from_connection(conn: Connection) -> Self {
100        Self { conn }
101    }
102
103    /// Open the Zotero database in read-only mode.
104    pub fn open(path: &Path) -> Result<Self> {
105        let uri = format!("file:{}?mode=ro", path.display());
106        let conn = Connection::open_with_flags(
107            &uri,
108            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI,
109        )
110        .with_context(|| format!("Failed to open Zotero database: {}", path.display()))?;
111        Ok(Self { conn })
112    }
113
114    // -----------------------------------------------------------------------
115    // Item queries
116    // -----------------------------------------------------------------------
117
118    /// Count non-deleted, substantive items (excludes attachments/notes).
119    pub fn item_count(&self) -> Result<i64> {
120        let count: i64 = self.conn.query_row(
121            "SELECT COUNT(*) FROM items i
122             WHERE i.libraryID = 1
123             AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
124             AND i.itemTypeID NOT IN (
125                 SELECT itemTypeID FROM itemTypes
126                 WHERE typeName IN ('attachment', 'note', 'annotation')
127             )",
128            [],
129            |row| row.get(0),
130        )?;
131        Ok(count)
132    }
133
134    /// Get an item by its 8-char Zotero key (e.g., "9MS26VH5").
135    pub fn item_by_key(&self, key: &str) -> Result<Option<ZoteroItem>> {
136        let row = self.conn.query_row(
137            "SELECT i.itemID, i.key, it.typeName, i.dateAdded, i.dateModified
138             FROM items i
139             JOIN itemTypes it ON i.itemTypeID = it.itemTypeID
140             WHERE i.key = ?1 AND i.libraryID = 1
141             AND i.itemID NOT IN (SELECT itemID FROM deletedItems)",
142            [key],
143            |row| {
144                Ok((
145                    row.get::<_, i64>(0)?,
146                    row.get::<_, String>(1)?,
147                    row.get::<_, String>(2)?,
148                    row.get::<_, String>(3)?,
149                    row.get::<_, String>(4)?,
150                ))
151            },
152        );
153        match row {
154            Ok((item_id, item_key, item_type, date_added, date_modified)) => {
155                let metadata = self.item_metadata(item_id)?;
156                let creators = self.item_creators(item_id)?;
157                let tags = self.item_tags(item_id)?;
158                Ok(Some(ZoteroItem {
159                    item_id,
160                    item_key,
161                    item_type,
162                    title: metadata.get("title").cloned().unwrap_or_default(),
163                    date: metadata.get("date").cloned(),
164                    doi: metadata.get("DOI").cloned(),
165                    url: metadata.get("url").cloned(),
166                    abstract_note: metadata.get("abstractNote").cloned(),
167                    creators,
168                    tags,
169                    date_added,
170                    date_modified,
171                }))
172            }
173            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
174            Err(e) => Err(e.into()),
175        }
176    }
177
178    /// Search items by title, DOI, or abstract (LIKE query).
179    ///
180    /// This is a brute-force scan (~8ms on 2700 items). For better performance,
181    /// we could add FTS5, but this is already 100x faster than the BBT RPC path.
182    pub fn search_items(&self, query: &str, limit: usize) -> Result<Vec<(i64, String)>> {
183        // Escape LIKE wildcards and the escape char itself in user input
184        let escaped = query
185            .replace('\\', "\\\\")
186            .replace('%', "\\%")
187            .replace('_', "\\_");
188        let pattern = format!("%{escaped}%");
189        let mut stmt = self.conn.prepare_cached(
190            "SELECT DISTINCT i.itemID, i.key
191             FROM items i
192             JOIN itemData id ON i.itemID = id.itemID
193             JOIN itemDataValues iv ON id.valueID = iv.valueID
194             WHERE iv.value LIKE ?1 ESCAPE '\\'
195             AND i.libraryID = 1
196             AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
197             AND i.itemTypeID NOT IN (
198                 SELECT itemTypeID FROM itemTypes
199                 WHERE typeName IN ('attachment', 'note', 'annotation')
200             )
201             LIMIT ?2",
202        )?;
203        let rows = stmt.query_map(rusqlite::params![pattern, limit as i64], |row| {
204            Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
205        })?;
206        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
207    }
208
209    /// Get recently modified items.
210    pub fn recent_items(&self, limit: usize) -> Result<Vec<(i64, String)>> {
211        let mut stmt = self.conn.prepare_cached(
212            "SELECT i.itemID, i.key
213             FROM items i
214             WHERE i.libraryID = 1
215             AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
216             AND i.itemTypeID NOT IN (
217                 SELECT itemTypeID FROM itemTypes
218                 WHERE typeName IN ('attachment', 'note', 'annotation')
219             )
220             ORDER BY i.dateModified DESC
221             LIMIT ?1",
222        )?;
223        let rows = stmt.query_map([limit as i64], |row| {
224            Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
225        })?;
226        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
227    }
228
229    // -----------------------------------------------------------------------
230    // Item metadata (EAV assembly)
231    // -----------------------------------------------------------------------
232
233    /// Get all metadata fields for an item as a key-value map.
234    ///
235    /// Joins through the EAV schema: `itemData → fields + itemDataValues`.
236    pub fn item_metadata(&self, item_id: i64) -> Result<HashMap<String, String>> {
237        let mut stmt = self.conn.prepare_cached(
238            "SELECT f.fieldName, iv.value
239             FROM itemData id
240             JOIN fields f ON id.fieldID = f.fieldID
241             JOIN itemDataValues iv ON id.valueID = iv.valueID
242             WHERE id.itemID = ?1",
243        )?;
244        let rows = stmt.query_map([item_id], |row| {
245            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
246        })?;
247        let mut map = HashMap::new();
248        for row in rows {
249            let (k, v) = row?;
250            map.insert(k, v);
251        }
252        Ok(map)
253    }
254
255    /// Get creators (authors/editors) for an item, ordered.
256    pub fn item_creators(&self, item_id: i64) -> Result<Vec<Creator>> {
257        let mut stmt = self.conn.prepare_cached(
258            "SELECT c.firstName, c.lastName, ct.creatorType, ic.orderIndex
259             FROM itemCreators ic
260             JOIN creators c ON ic.creatorID = c.creatorID
261             JOIN creatorTypes ct ON ic.creatorTypeID = ct.creatorTypeID
262             WHERE ic.itemID = ?1
263             ORDER BY ic.orderIndex",
264        )?;
265        let rows = stmt.query_map([item_id], |row| {
266            Ok(Creator {
267                first_name: row.get(0)?,
268                last_name: row.get(1)?,
269                creator_type: row.get(2)?,
270                order: row.get(3)?,
271            })
272        })?;
273        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
274    }
275
276    /// Get tags for an item.
277    pub fn item_tags(&self, item_id: i64) -> Result<Vec<String>> {
278        let mut stmt = self.conn.prepare_cached(
279            "SELECT DISTINCT t.name FROM tags t
280             JOIN itemTags it ON t.tagID = it.tagID
281             WHERE it.itemID = ?1",
282        )?;
283        let rows = stmt.query_map([item_id], |row| row.get::<_, String>(0))?;
284        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
285    }
286
287    // -----------------------------------------------------------------------
288    // Collections
289    // -----------------------------------------------------------------------
290
291    /// List all collections with their hierarchy.
292    pub fn collections(&self) -> Result<Vec<Collection>> {
293        let mut stmt = self.conn.prepare_cached(
294            "SELECT c.collectionID, c.key, c.collectionName,
295                    pc.key as parentKey
296             FROM collections c
297             LEFT JOIN collections pc ON c.parentCollectionID = pc.collectionID
298             WHERE c.libraryID = 1",
299        )?;
300        let rows = stmt.query_map([], |row| {
301            Ok(Collection {
302                collection_id: row.get(0)?,
303                key: row.get(1)?,
304                name: row.get(2)?,
305                parent_key: row.get(3)?,
306            })
307        })?;
308        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
309    }
310
311    /// Get item keys in a collection.
312    pub fn collection_items(
313        &self,
314        collection_key: &str,
315        limit: usize,
316    ) -> Result<Vec<(i64, String)>> {
317        let mut stmt = self.conn.prepare_cached(
318            "SELECT i.itemID, i.key
319             FROM items i
320             JOIN collectionItems ci ON i.itemID = ci.itemID
321             JOIN collections c ON ci.collectionID = c.collectionID
322             WHERE c.key = ?1 AND i.libraryID = 1
323             AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
324             AND i.itemTypeID NOT IN (
325                 SELECT itemTypeID FROM itemTypes
326                 WHERE typeName IN ('attachment', 'note', 'annotation')
327             )
328             LIMIT ?2",
329        )?;
330        let rows = stmt.query_map(rusqlite::params![collection_key, limit as i64], |row| {
331            Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
332        })?;
333        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
334    }
335
336    // -----------------------------------------------------------------------
337    // Attachments
338    // -----------------------------------------------------------------------
339
340    /// Get attachments for an item, including content hash.
341    ///
342    /// The `storage_hash` field is an MD5 hash computed by Zotero on import.
343    /// External tools can use it for content-identity verification and
344    /// deduplication without Biblion needing to know about consumers.
345    pub fn item_attachments(&self, item_id: i64) -> Result<Vec<Attachment>> {
346        let mut stmt = self.conn.prepare_cached(
347            "SELECT i.key, ia.contentType, ia.path,
348                    (SELECT iv.value FROM itemData id2
349                     JOIN itemDataValues iv ON id2.valueID = iv.valueID
350                     JOIN fields f ON id2.fieldID = f.fieldID
351                     WHERE id2.itemID = ia.itemID AND f.fieldName = 'title') as title,
352                    ia.storageHash
353             FROM itemAttachments ia
354             JOIN items i ON ia.itemID = i.itemID
355             WHERE ia.parentItemID = ?1",
356        )?;
357        let rows = stmt.query_map([item_id], |row| {
358            Ok(Attachment {
359                item_key: row.get(0)?,
360                content_type: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
361                path: row.get(2)?,
362                title: row.get(3)?,
363                storage_hash: row.get(4)?,
364            })
365        })?;
366        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
367    }
368
369    // -----------------------------------------------------------------------
370    // Notes
371    // -----------------------------------------------------------------------
372
373    /// Get HTML notes for an item.
374    pub fn item_notes(&self, item_id: i64) -> Result<Vec<String>> {
375        let mut stmt = self.conn.prepare_cached(
376            "SELECT in2.note FROM itemNotes in2
377             JOIN items i ON in2.itemID = i.itemID
378             WHERE in2.parentItemID = ?1
379             AND i.itemID NOT IN (SELECT itemID FROM deletedItems)",
380        )?;
381        let rows = stmt.query_map([item_id], |row| row.get::<_, String>(0))?;
382        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
383    }
384
385    /// Count collections.
386    pub fn collection_count(&self) -> Result<i64> {
387        let count: i64 = self.conn.query_row(
388            "SELECT COUNT(*) FROM collections WHERE libraryID = 1",
389            [],
390            |row| row.get(0),
391        )?;
392        Ok(count)
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    /// Create an in-memory Zotero database with realistic test data.
401    ///
402    /// This mirrors the real Zotero schema closely enough to test our
403    /// queries without needing the actual 71MB database file.
404    fn test_zotero_db() -> ZoteroDb {
405        let conn = Connection::open_in_memory().unwrap();
406        conn.execute_batch(
407            "
408            -- Core tables
409            CREATE TABLE libraries (libraryID INTEGER PRIMARY KEY);
410            INSERT INTO libraries VALUES (1);
411
412            CREATE TABLE itemTypes (itemTypeID INTEGER PRIMARY KEY, typeName TEXT);
413            INSERT INTO itemTypes VALUES (1, 'journalArticle');
414            INSERT INTO itemTypes VALUES (2, 'book');
415            INSERT INTO itemTypes VALUES (14, 'attachment');
416            INSERT INTO itemTypes VALUES (15, 'note');
417            INSERT INTO itemTypes VALUES (29, 'annotation');
418
419            CREATE TABLE fields (fieldID INTEGER PRIMARY KEY, fieldName TEXT);
420            INSERT INTO fields VALUES (110, 'title');
421            INSERT INTO fields VALUES (14, 'date');
422            INSERT INTO fields VALUES (26, 'DOI');
423            INSERT INTO fields VALUES (1, 'url');
424            INSERT INTO fields VALUES (90, 'abstractNote');
425
426            CREATE TABLE items (
427                itemID INTEGER PRIMARY KEY, itemTypeID INT, dateAdded TEXT,
428                dateModified TEXT, libraryID INT, key TEXT UNIQUE
429            );
430            INSERT INTO items VALUES (1, 1, '2024-01-01', '2024-06-15', 1, 'ABC12345');
431            INSERT INTO items VALUES (2, 2, '2024-02-01', '2024-05-10', 1, 'DEF67890');
432            INSERT INTO items VALUES (3, 14, '2024-01-01', '2024-01-01', 1, 'ATT00001');
433            INSERT INTO items VALUES (4, 15, '2024-03-01', '2024-03-01', 1, 'NOTE0001');
434
435            CREATE TABLE deletedItems (itemID INTEGER PRIMARY KEY, dateDeleted TEXT);
436
437            CREATE TABLE itemDataValues (valueID INTEGER PRIMARY KEY, value TEXT UNIQUE);
438            INSERT INTO itemDataValues VALUES (1, 'Hints on Test Data Selection');
439            INSERT INTO itemDataValues VALUES (2, '1978');
440            INSERT INTO itemDataValues VALUES (3, '10.1109/C-M.1978.218136');
441            INSERT INTO itemDataValues VALUES (4, 'The Art of Testing');
442            INSERT INTO itemDataValues VALUES (5, '2020');
443            INSERT INTO itemDataValues VALUES (6, 'Abstract about mutation testing');
444
445            CREATE TABLE itemData (itemID INT, fieldID INT, valueID INT, PRIMARY KEY (itemID, fieldID));
446            INSERT INTO itemData VALUES (1, 110, 1);  -- title
447            INSERT INTO itemData VALUES (1, 14, 2);   -- date
448            INSERT INTO itemData VALUES (1, 26, 3);   -- DOI
449            INSERT INTO itemData VALUES (1, 90, 6);   -- abstract
450            INSERT INTO itemData VALUES (2, 110, 4);  -- title
451            INSERT INTO itemData VALUES (2, 14, 5);   -- date
452
453            -- Creators
454            CREATE TABLE creatorTypes (creatorTypeID INTEGER PRIMARY KEY, creatorType TEXT);
455            INSERT INTO creatorTypes VALUES (1, 'author');
456            INSERT INTO creatorTypes VALUES (2, 'editor');
457
458            CREATE TABLE creators (creatorID INTEGER PRIMARY KEY, firstName TEXT, lastName TEXT, fieldMode INT);
459            INSERT INTO creators VALUES (1, 'Richard', 'DeMillo', 0);
460            INSERT INTO creators VALUES (2, 'Richard', 'Lipton', 0);
461
462            CREATE TABLE itemCreators (itemID INT, creatorID INT, creatorTypeID INT, orderIndex INT);
463            INSERT INTO itemCreators VALUES (1, 1, 1, 0);
464            INSERT INTO itemCreators VALUES (1, 2, 1, 1);
465
466            -- Tags
467            CREATE TABLE tags (tagID INTEGER PRIMARY KEY, name TEXT UNIQUE);
468            INSERT INTO tags VALUES (1, 'mutation-testing');
469            INSERT INTO tags VALUES (2, 'foundational');
470
471            CREATE TABLE itemTags (itemID INT, tagID INT, type INT, PRIMARY KEY (itemID, tagID));
472            INSERT INTO itemTags VALUES (1, 1, 0);
473            INSERT INTO itemTags VALUES (1, 2, 0);
474
475            -- Collections
476            CREATE TABLE collections (
477                collectionID INTEGER PRIMARY KEY, collectionName TEXT,
478                parentCollectionID INT, libraryID INT, key TEXT UNIQUE
479            );
480            INSERT INTO collections VALUES (1, 'Mutation Testing', NULL, 1, 'COL00001');
481            INSERT INTO collections VALUES (2, 'Foundational', 1, 1, 'COL00002');
482
483            CREATE TABLE collectionItems (collectionID INT, itemID INT, orderIndex INT);
484            INSERT INTO collectionItems VALUES (1, 1, 0);
485            INSERT INTO collectionItems VALUES (1, 2, 1);
486
487            -- Attachments
488            CREATE TABLE itemAttachments (
489                itemID INT PRIMARY KEY, parentItemID INT,
490                linkMode INT, contentType TEXT, path TEXT,
491                charsetID INT, syncState INT, storageModTime INT,
492                storageHash TEXT, lastProcessedModificationTime INT
493            );
494            INSERT INTO itemAttachments VALUES (3, 1, 1, 'application/pdf', 'storage:DeMillo1978.pdf',
495                                                 NULL, 0, NULL, 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4', NULL);
496
497            -- Notes
498            CREATE TABLE itemNotes (itemID INT PRIMARY KEY, parentItemID INT, note TEXT, title TEXT);
499            INSERT INTO itemNotes VALUES (4, 1, '<p>Great foundational paper on mutation testing.</p>', '');
500            ",
501        )
502        .unwrap();
503        ZoteroDb { conn }
504    }
505
506    // -----------------------------------------------------------------------
507    // Item queries
508    // -----------------------------------------------------------------------
509
510    #[test]
511    fn item_count_excludes_attachments_and_notes() {
512        let db = test_zotero_db();
513        let count = db.item_count().unwrap();
514        assert_eq!(count, 2); // Only journalArticle + book, not attachment/note
515    }
516
517    #[test]
518    fn item_by_key_found() {
519        let db = test_zotero_db();
520        let item = db.item_by_key("ABC12345").unwrap().unwrap();
521        assert_eq!(item.title, "Hints on Test Data Selection");
522        assert_eq!(item.item_type, "journalArticle");
523        assert_eq!(item.doi, Some("10.1109/C-M.1978.218136".into()));
524        assert_eq!(item.date, Some("1978".into()));
525        assert_eq!(
526            item.abstract_note,
527            Some("Abstract about mutation testing".into())
528        );
529    }
530
531    #[test]
532    fn item_by_key_not_found() {
533        let db = test_zotero_db();
534        assert!(db.item_by_key("ZZZZZZZZ").unwrap().is_none());
535    }
536
537    #[test]
538    fn item_by_key_creators_ordered() {
539        let db = test_zotero_db();
540        let item = db.item_by_key("ABC12345").unwrap().unwrap();
541        assert_eq!(item.creators.len(), 2);
542        assert_eq!(item.creators[0].last_name, "DeMillo");
543        assert_eq!(item.creators[1].last_name, "Lipton");
544        assert_eq!(item.creators[0].order, 0);
545        assert_eq!(item.creators[1].order, 1);
546    }
547
548    #[test]
549    fn item_by_key_tags() {
550        let db = test_zotero_db();
551        let item = db.item_by_key("ABC12345").unwrap().unwrap();
552        assert_eq!(item.tags.len(), 2);
553        assert!(item.tags.contains(&"mutation-testing".to_string()));
554        assert!(item.tags.contains(&"foundational".to_string()));
555    }
556
557    // -----------------------------------------------------------------------
558    // Search
559    // -----------------------------------------------------------------------
560
561    #[test]
562    fn search_by_title() {
563        let db = test_zotero_db();
564        let results = db.search_items("Hints", 10).unwrap();
565        assert_eq!(results.len(), 1);
566        assert_eq!(results[0].1, "ABC12345");
567    }
568
569    #[test]
570    fn search_by_doi() {
571        let db = test_zotero_db();
572        let results = db.search_items("10.1109", 10).unwrap();
573        assert_eq!(results.len(), 1);
574    }
575
576    #[test]
577    fn search_by_abstract() {
578        let db = test_zotero_db();
579        let results = db.search_items("mutation testing", 10).unwrap();
580        assert_eq!(results.len(), 1);
581    }
582
583    #[test]
584    fn search_no_results() {
585        let db = test_zotero_db();
586        let results = db.search_items("quantum computing", 10).unwrap();
587        assert!(results.is_empty());
588    }
589
590    #[test]
591    fn search_respects_limit() {
592        let db = test_zotero_db();
593        let results = db.search_items("t", 1).unwrap(); // matches both items
594        assert_eq!(results.len(), 1);
595    }
596
597    // -----------------------------------------------------------------------
598    // Recent items
599    // -----------------------------------------------------------------------
600
601    #[test]
602    fn recent_items_ordered_by_date_modified() {
603        let db = test_zotero_db();
604        let results = db.recent_items(10).unwrap();
605        assert_eq!(results.len(), 2);
606        // ABC12345 modified 2024-06-15, DEF67890 modified 2024-05-10
607        assert_eq!(results[0].1, "ABC12345");
608        assert_eq!(results[1].1, "DEF67890");
609    }
610
611    // -----------------------------------------------------------------------
612    // Collections
613    // -----------------------------------------------------------------------
614
615    #[test]
616    fn collections_with_hierarchy() {
617        let db = test_zotero_db();
618        let colls = db.collections().unwrap();
619        assert_eq!(colls.len(), 2);
620        let parent = colls.iter().find(|c| c.name == "Mutation Testing").unwrap();
621        assert!(parent.parent_key.is_none());
622        let child = colls.iter().find(|c| c.name == "Foundational").unwrap();
623        assert_eq!(child.parent_key, Some("COL00001".into()));
624    }
625
626    #[test]
627    fn collection_items_found() {
628        let db = test_zotero_db();
629        let items = db.collection_items("COL00001", 10).unwrap();
630        assert_eq!(items.len(), 2);
631    }
632
633    #[test]
634    fn collection_items_not_found() {
635        let db = test_zotero_db();
636        let items = db.collection_items("ZZZZZZZZ", 10).unwrap();
637        assert!(items.is_empty());
638    }
639
640    // -----------------------------------------------------------------------
641    // Attachments
642    // -----------------------------------------------------------------------
643
644    #[test]
645    fn item_attachments_found() {
646        let db = test_zotero_db();
647        let attachments = db.item_attachments(1).unwrap();
648        assert_eq!(attachments.len(), 1);
649        assert_eq!(attachments[0].content_type, "application/pdf");
650        assert_eq!(attachments[0].path, Some("storage:DeMillo1978.pdf".into()));
651        assert_eq!(
652            attachments[0].storage_hash,
653            Some("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4".into())
654        );
655    }
656
657    #[test]
658    fn item_attachments_empty() {
659        let db = test_zotero_db();
660        let attachments = db.item_attachments(2).unwrap();
661        assert!(attachments.is_empty());
662    }
663
664    // -----------------------------------------------------------------------
665    // Notes
666    // -----------------------------------------------------------------------
667
668    #[test]
669    fn item_notes_found() {
670        let db = test_zotero_db();
671        let notes = db.item_notes(1).unwrap();
672        assert_eq!(notes.len(), 1);
673        assert!(notes[0].contains("foundational paper"));
674    }
675
676    #[test]
677    fn item_notes_empty() {
678        let db = test_zotero_db();
679        let notes = db.item_notes(2).unwrap();
680        assert!(notes.is_empty());
681    }
682
683    // -----------------------------------------------------------------------
684    // Collection count
685    // -----------------------------------------------------------------------
686
687    #[test]
688    fn collection_count() {
689        let db = test_zotero_db();
690        assert_eq!(db.collection_count().unwrap(), 2);
691    }
692}