Skip to main content

research_agent/application/
zotero_export.rs

1//! Push library tags into a running Zotero: the narrow write slice from the
2//! roadmap. Papers are matched to Zotero items by normalized DOI; papers
3//! without a DOI, without a Zotero counterpart, or whose Zotero item changed
4//! since the read are skipped and counted, never guessed at. Dry-run is the
5//! default — the caller opts into writes with an explicit flag, and nothing
6//! here is exposed over MCP (a CLI user pressing the button is the trust
7//! boundary).
8
9use async_trait::async_trait;
10
11use crate::adapters::bib_importer::normalize_doi;
12use crate::adapters::zotero_write::{RemoteItem, merge_tags};
13use crate::domain::paper::Paper;
14use crate::error::Result;
15use crate::ports::index_store::IndexStore;
16
17/// The write half's storage contract, split from the HTTP adapter so the
18/// dry-run/conflict logic is testable offline.
19#[async_trait]
20pub trait ZoteroTagSink: Send + Sync {
21    async fn library(&self) -> Result<Vec<RemoteItem>>;
22    /// `Ok(false)` = version conflict, item changed in Zotero: skip, report.
23    async fn write_tags(&self, key: &str, version: i64, tags: &[String]) -> Result<bool>;
24}
25
26/// What one export pass did (or would do, in dry-run). Every skip reason is
27/// counted, not collapsed into a silent total.
28#[derive(Debug, Default, PartialEq)]
29pub struct ExportReport {
30    /// Papers whose tags were written (`apply`) — or would be written
31    /// (dry-run) — as `title: added tags`.
32    pub updates: Vec<String>,
33    /// Papers with no tags to add whose Zotero item already matches.
34    pub unchanged: usize,
35    pub skipped_no_doi: usize,
36    pub skipped_not_in_zotero: usize,
37    /// Several Zotero items share the paper's DOI; picking one would be a
38    /// guess, so all of them are left alone.
39    pub skipped_ambiguous: usize,
40    /// Version conflict: the Zotero item changed since the read. Never
41    /// merged; the user resolves it in Zotero.
42    pub skipped_conflict: usize,
43}
44
45impl ExportReport {
46    /// One human line for the CLI: totals first, then up to a few updates.
47    pub fn summary(&self) -> String {
48        let mut line = format!(
49            "{} matched, {} unchanged, {} without DOI, {} not in Zotero, {} ambiguous DOI, {} changed in Zotero",
50            self.updates.len(),
51            self.unchanged,
52            self.skipped_no_doi,
53            self.skipped_not_in_zotero,
54            self.skipped_ambiguous,
55            self.skipped_conflict,
56        );
57        if let Some(first) = self.updates.first() {
58            line.push_str(&format!("\n  e.g. {first}"));
59            if self.updates.len() > 1 {
60                line.push_str(&format!("\n  ... and {} more", self.updates.len() - 1));
61            }
62        }
63        line
64    }
65}
66
67/// Match every library paper carrying a DOI to its Zotero item and push the
68/// union of the two tag sets. With `apply = false` nothing is written: the
69/// sink is never called and the report describes what *would* happen.
70pub async fn export_tags_to_zotero(
71    store: &dyn IndexStore,
72    sink: &dyn ZoteroTagSink,
73    apply: bool,
74) -> Result<ExportReport> {
75    let remote = sink.library().await?;
76    // Normalize both sides at match time, so the exporter never depends on
77    // the sink (or the import path) having normalized already. Items sharing
78    // a DOI are collected: a match must be unambiguous or it is skipped.
79    let mut by_doi: std::collections::HashMap<String, Vec<&RemoteItem>> =
80        std::collections::HashMap::new();
81    for item in &remote {
82        if let Some(doi) = item.doi.as_deref().and_then(normalize_doi) {
83            by_doi.entry(doi).or_default().push(item);
84        }
85    }
86
87    let mut report = ExportReport::default();
88    // Papers are grouped by DOI before any write: siblings sharing a DOI
89    // target one Zotero item, so their tag sets must merge into a single
90    // write. Writing per paper would let the second paper's tags be judged
91    // against a tag set the first one already pushed, and silently dropped.
92    let mut groups: Vec<(String, Vec<&Paper>)> = Vec::new();
93    let mut group_of: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
94    let papers = store.list_papers(None)?;
95    for paper in &papers {
96        // Stored DOIs are normalized at import; normalizing again is free and
97        // keeps matching exact even for rows that predate that guarantee.
98        let Some(doi) = paper.doi.as_deref().and_then(normalize_doi) else {
99            report.skipped_no_doi += 1;
100            continue;
101        };
102        if !by_doi.contains_key(&doi) {
103            report.skipped_not_in_zotero += 1;
104            continue;
105        }
106        match group_of.get(&doi) {
107            Some(&idx) => groups[idx].1.push(paper),
108            None => {
109                group_of.insert(doi.clone(), groups.len());
110                groups.push((doi, vec![paper]));
111            }
112        }
113    }
114
115    for (doi, members) in groups {
116        let items = &by_doi[&doi];
117        let &[item] = items.as_slice() else {
118            report.skipped_ambiguous += members.len();
119            continue;
120        };
121        // Compare against the item's tags the way merge would store them:
122        // padding-only differences are not a change worth a write.
123        let current: Vec<String> = item
124            .tags
125            .iter()
126            .map(|t| t.trim().to_string())
127            .filter(|t| !t.is_empty())
128            .collect();
129        // Union of every sibling's tags, so one write carries them all.
130        let mut merged = current.clone();
131        for paper in &members {
132            merged = merge_tags(&merged, &paper.tags);
133        }
134        if merged == current {
135            report.unchanged += members.len();
136            continue;
137        }
138        // New-tag count measured against the trimmed tag set merge started
139        // from: item.tags may carry whitespace-only entries merge drops, so
140        // subtracting its length could underflow.
141        let added = merged.len() - current.len();
142        let titles: Vec<String> = members
143            .iter()
144            .map(|p| format!("{}: +{added} tag(s)", p.title))
145            .collect();
146        if !apply {
147            report.updates.extend(titles);
148            continue;
149        }
150        if sink.write_tags(&item.key, item.version, &merged).await? {
151            report.updates.extend(titles);
152        } else {
153            // Version conflict: the item changed in Zotero since the read.
154            // Retrying with the same stale version is doomed, so every
155            // sibling on this DOI is reported skipped, not re-attempted.
156            report.skipped_conflict += members.len();
157        }
158    }
159    Ok(report)
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use std::sync::Mutex;
166
167    struct FakeSink {
168        items: Vec<RemoteItem>,
169        /// (key, tags) of every write attempt.
170        writes: Mutex<Vec<(String, Vec<String>)>>,
171        /// Keys that answer with a version conflict.
172        conflicts: Vec<String>,
173    }
174
175    impl FakeSink {
176        fn new(items: Vec<RemoteItem>) -> Self {
177            Self {
178                items,
179                writes: Mutex::new(Vec::new()),
180                conflicts: Vec::new(),
181            }
182        }
183    }
184
185    #[async_trait]
186    impl ZoteroTagSink for FakeSink {
187        async fn library(&self) -> Result<Vec<RemoteItem>> {
188            Ok(self.items.clone())
189        }
190        async fn write_tags(&self, key: &str, _version: i64, tags: &[String]) -> Result<bool> {
191            self.writes
192                .lock()
193                .unwrap()
194                .push((key.to_string(), tags.to_vec()));
195            Ok(!self.conflicts.iter().any(|c| c == key))
196        }
197    }
198
199    fn remote(key: &str, doi: &str, tags: &[&str]) -> RemoteItem {
200        RemoteItem {
201            key: key.into(),
202            version: 7,
203            doi: Some(doi.into()),
204            tags: tags.iter().map(|t| (*t).into()).collect(),
205        }
206    }
207
208    fn paper(title: &str, doi: &str, tags: &[&str]) -> Paper {
209        let mut p = Paper::new(title.into());
210        p.doi = Some(doi.into());
211        p.tags = tags.iter().map(|t| (*t).into()).collect();
212        p
213    }
214
215    async fn run(papers: Vec<Paper>, sink: &FakeSink, apply: bool) -> ExportReport {
216        let dir = tempfile::tempdir().unwrap();
217        let store =
218            crate::adapters::sqlite_store::SqliteStore::open(&dir.path().join("t.db")).unwrap();
219        for p in &papers {
220            store.insert_paper(p).unwrap();
221        }
222        export_tags_to_zotero(&store, sink, apply).await.unwrap()
223    }
224
225    #[tokio::test]
226    async fn dry_run_reports_without_writing() {
227        let sink = FakeSink::new(vec![remote("K1", "10.1/a", &["zotero-tag"])]);
228        let report = run(
229            vec![paper("P", "10.1/a", &["zotero-tag", "mine"])],
230            &sink,
231            false,
232        )
233        .await;
234        assert_eq!(report.updates.len(), 1);
235        assert!(
236            sink.writes.lock().unwrap().is_empty(),
237            "dry-run must not write"
238        );
239    }
240
241    #[tokio::test]
242    async fn apply_merges_and_writes_union() {
243        let sink = FakeSink::new(vec![remote("K1", "10.1/a", &["zotero-tag"])]);
244        let report = run(vec![paper("P", "10.1/a", &["mine"])], &sink, true).await;
245        assert_eq!(report.updates.len(), 1);
246        let writes = sink.writes.lock().unwrap();
247        assert_eq!(writes.len(), 1);
248        assert_eq!(writes[0].0, "K1");
249        assert_eq!(writes[0].1, vec!["zotero-tag", "mine"]);
250    }
251
252    #[tokio::test]
253    async fn doi_matching_ignores_case_and_resolver_prefix() {
254        let sink = FakeSink::new(vec![remote("K1", "10.1/A", &[])]);
255        let report = run(
256            vec![paper("P", "https://doi.org/10.1/a", &["mine"])],
257            &sink,
258            true,
259        )
260        .await;
261        assert_eq!(report.updates.len(), 1, "normalized DOIs match");
262    }
263
264    #[tokio::test]
265    async fn unchanged_items_are_counted_not_written() {
266        let sink = FakeSink::new(vec![remote("K1", "10.1/a", &["same"])]);
267        let report = run(vec![paper("P", "10.1/a", &["same"])], &sink, true).await;
268        assert_eq!(report.unchanged, 1);
269        assert!(report.updates.is_empty());
270        assert!(sink.writes.lock().unwrap().is_empty());
271    }
272
273    #[tokio::test]
274    async fn skips_are_counted_by_reason() {
275        let sink = FakeSink::new(vec![remote("K1", "10.1/a", &[])]);
276        let report = run(
277            vec![
278                paper("has doi but absent", "10.1/missing", &[]),
279                paper("no doi at all", "", &[]),
280            ],
281            &sink,
282            false,
283        )
284        .await;
285        assert_eq!(report.skipped_not_in_zotero, 1);
286        assert_eq!(report.skipped_no_doi, 1);
287    }
288
289    #[tokio::test]
290    async fn version_conflicts_skip_and_count() {
291        let mut sink = FakeSink::new(vec![remote("K1", "10.1/a", &["old"])]);
292        sink.conflicts.push("K1".into());
293        let report = run(vec![paper("P", "10.1/a", &["mine"])], &sink, true).await;
294        assert_eq!(report.skipped_conflict, 1);
295        assert!(report.updates.is_empty());
296    }
297
298    #[test]
299    fn summary_names_every_bucket() {
300        let report = ExportReport {
301            updates: vec!["T: +1 tag(s)".into()],
302            unchanged: 2,
303            skipped_no_doi: 3,
304            skipped_not_in_zotero: 4,
305            skipped_ambiguous: 6,
306            skipped_conflict: 5,
307        };
308        let s = report.summary();
309        for needle in [
310            "1 matched",
311            "2 unchanged",
312            "3 without DOI",
313            "4 not in Zotero",
314            "6 ambiguous DOI",
315            "5 changed in Zotero",
316        ] {
317            assert!(s.contains(needle), "summary missing {needle}: {s}");
318        }
319    }
320
321    #[tokio::test]
322    async fn whitespace_only_tag_difference_counts_as_unchanged() {
323        // merge trims: the Zotero item's " diamond " collapses to "diamond",
324        // which the library already has — that must not become a write.
325        let sink = FakeSink::new(vec![RemoteItem {
326            key: "K1".into(),
327            version: 7,
328            doi: Some("10.1/a".into()),
329            tags: vec![" diamond ".into()],
330        }]);
331        let report = run(vec![paper("P", "10.1/a", &["diamond"])], &sink, true).await;
332        assert_eq!(report.unchanged, 1);
333        assert!(sink.writes.lock().unwrap().is_empty());
334    }
335
336    #[tokio::test]
337    async fn ambiguous_doi_skips_instead_of_guessing() {
338        let sink = FakeSink::new(vec![
339            remote("K1", "10.1/a", &["one"]),
340            remote("K2", "10.1/a", &["two"]),
341        ]);
342        let report = run(vec![paper("P", "10.1/a", &["mine"])], &sink, true).await;
343        assert_eq!(report.skipped_ambiguous, 1);
344        assert!(report.updates.is_empty());
345        assert!(sink.writes.lock().unwrap().is_empty());
346    }
347
348    #[tokio::test]
349    async fn update_count_ignores_whitespace_only_item_tags() {
350        // The item's two whitespace-only tags are dropped by merge; counting
351        // against raw item.tags would underflow usize. New-tag count is 1.
352        let sink = FakeSink::new(vec![RemoteItem {
353            key: "K1".into(),
354            version: 7,
355            doi: Some("10.1/a".into()),
356            tags: vec![" ".into(), "  ".into()],
357        }]);
358        let report = run(vec![paper("P", "10.1/a", &["mine"])], &sink, true).await;
359        assert_eq!(report.updates, vec!["P: +1 tag(s)"]);
360    }
361
362    #[tokio::test]
363    async fn sibling_papers_on_one_doi_contribute_all_their_tags() {
364        // P1 and P2 share a DOI, so they target the same Zotero item. One
365        // write must carry both tag sets: judging P2 against the set P1 just
366        // pushed would drop "y" silently.
367        let sink = FakeSink::new(vec![remote("K1", "10.1/a", &["old"])]);
368        let report = run(
369            vec![paper("P1", "10.1/a", &["x"]), paper("P2", "10.1/a", &["y"])],
370            &sink,
371            true,
372        )
373        .await;
374        let writes = sink.writes.lock().unwrap();
375        assert_eq!(writes.len(), 1, "one item, one write");
376        // Sibling order follows the store's row order, which is not part of
377        // the contract; only the tag set is.
378        let mut written = writes[0].1.clone();
379        written.sort();
380        assert_eq!(written, vec!["old", "x", "y"]);
381        assert_eq!(report.updates.len(), 2, "both papers reported");
382    }
383
384    #[tokio::test]
385    async fn sibling_papers_adding_nothing_are_all_unchanged() {
386        let sink = FakeSink::new(vec![remote("K1", "10.1/a", &["same"])]);
387        let report = run(
388            vec![
389                paper("P1", "10.1/a", &["same"]),
390                paper("P2", "10.1/a", &["same"]),
391            ],
392            &sink,
393            true,
394        )
395        .await;
396        assert_eq!(report.unchanged, 2);
397        assert!(sink.writes.lock().unwrap().is_empty());
398    }
399
400    #[tokio::test]
401    async fn ambiguous_doi_counts_every_sibling_paper() {
402        let sink = FakeSink::new(vec![
403            remote("K1", "10.1/a", &["one"]),
404            remote("K2", "10.1/a", &["two"]),
405        ]);
406        let report = run(
407            vec![paper("P1", "10.1/a", &["x"]), paper("P2", "10.1/a", &["y"])],
408            &sink,
409            false,
410        )
411        .await;
412        assert_eq!(report.skipped_ambiguous, 2);
413    }
414
415    #[tokio::test]
416    async fn conflicting_item_is_not_retried_for_sibling_doi() {
417        // Both papers map to K1; the first write conflicts, so the second
418        // must skip without another doomed HTTP attempt.
419        let mut sink = FakeSink::new(vec![remote("K1", "10.1/a", &["old"])]);
420        sink.conflicts.push("K1".into());
421        let report = run(
422            vec![
423                paper("P1", "10.1/a", &["mine"]),
424                paper("P2", "10.1/a", &["mine"]),
425            ],
426            &sink,
427            true,
428        )
429        .await;
430        assert_eq!(report.skipped_conflict, 2);
431        assert_eq!(sink.writes.lock().unwrap().len(), 1);
432    }
433}