Skip to main content

scitadel_export/import/
matcher.rs

1//! Match a [`BibEntry`] to an existing paper via the cascade pinned
2//! in issue #134:
3//!
4//! 1. DOI (normalized)
5//! 2. arXiv id
6//! 3. PubMed id
7//! 4. OpenAlex id
8//! 5. **scitadel's own `bibtex_key`** — covers the round-trip case
9//!    where a `.bib` was previously exported by scitadel and is being
10//!    re-imported without ever leaving a DOI trail.
11//! 6. **Alias from `paper_aliases`** (#134 step 1) — covers the
12//!    re-import of a third-party `.bib` where the citekey was
13//!    preserved during the previous import. Ambiguous aliases (the
14//!    alias points at >1 paper) are skipped so the cascade falls
15//!    through to title+year instead of silently picking.
16//! 7. Title + year exact match (case-insensitive, normalized whitespace).
17//!    No fuzzy matching — that's out of scope for iter 2 and prone to
18//!    false positives.
19//!
20//! The matcher is isolated behind the [`PaperLookup`] trait so the
21//! cascade can be unit-tested without a SQLite fixture. The concrete
22//! impl backed by `SqlitePaperRepository` + `SqlitePaperAliasRepository`
23//! lives at the CLI/MCP wiring layer (step 5).
24
25use super::parse::BibEntry;
26use scitadel_core::error::CoreError;
27
28/// Narrow port the matcher depends on. Each method returns the
29/// matching paper's `id` (not the full `Paper`) so the cascade is
30/// allocation-light — merging + field diff happens upstream once the
31/// match is resolved.
32pub trait PaperLookup {
33    fn find_by_doi(&self, doi: &str) -> Result<Option<String>, CoreError>;
34    fn find_by_arxiv_id(&self, id: &str) -> Result<Option<String>, CoreError>;
35    fn find_by_pubmed_id(&self, id: &str) -> Result<Option<String>, CoreError>;
36    fn find_by_openalex_id(&self, id: &str) -> Result<Option<String>, CoreError>;
37    /// Scitadel's authoritative citation key (column `papers.bibtex_key`).
38    fn find_by_bibtex_key(&self, key: &str) -> Result<Option<String>, CoreError>;
39    /// All papers sharing this alias, for ambiguity detection. See
40    /// `SqlitePaperAliasRepository::lookup_all`.
41    fn find_by_alias(&self, alias: &str) -> Result<Vec<String>, CoreError>;
42    /// Case-insensitive exact title match, optionally constrained by year.
43    fn find_by_title_and_year(
44        &self,
45        title: &str,
46        year: Option<i32>,
47    ) -> Result<Option<String>, CoreError>;
48}
49
50/// Outcome of a single match attempt. Explicit `Ambiguous` variant so
51/// step 3's merge strategy layer can surface "you have two papers
52/// sharing an alias — pick one" interactively instead of silently
53/// picking whichever SQL returned first.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum MatchOutcome {
56    /// `(paper_id, strategy_that_matched)` — the strategy is surfaced
57    /// in the per-paper import summary line ("matched via DOI", "via
58    /// title+year", etc).
59    Matched(String, MatchStrategy),
60    /// No strategy produced a hit. New paper — import creates one.
61    NoMatch,
62    /// Alias resolved to multiple papers. Step 3 may prompt the user;
63    /// default is to fall through to title+year (which this variant
64    /// pre-computes so the caller doesn't re-run the cascade).
65    AmbiguousAlias(Vec<String>),
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum MatchStrategy {
70    Doi,
71    ArxivId,
72    PubmedId,
73    OpenalexId,
74    BibtexKey,
75    Alias,
76    TitleAndYear,
77}
78
79impl MatchStrategy {
80    pub fn as_str(self) -> &'static str {
81        match self {
82            Self::Doi => "doi",
83            Self::ArxivId => "arxiv_id",
84            Self::PubmedId => "pubmed_id",
85            Self::OpenalexId => "openalex_id",
86            Self::BibtexKey => "bibtex_key",
87            Self::Alias => "alias",
88            Self::TitleAndYear => "title+year",
89        }
90    }
91}
92
93/// Run the full cascade on one entry. Short-circuits on the first hit.
94pub fn match_entry(entry: &BibEntry, lookup: &dyn PaperLookup) -> Result<MatchOutcome, CoreError> {
95    if let Some(doi) = entry.doi.as_deref()
96        && let Some(id) = lookup.find_by_doi(doi)?
97    {
98        return Ok(MatchOutcome::Matched(id, MatchStrategy::Doi));
99    }
100    if let Some(arxiv) = entry.arxiv_id.as_deref()
101        && let Some(id) = lookup.find_by_arxiv_id(arxiv)?
102    {
103        return Ok(MatchOutcome::Matched(id, MatchStrategy::ArxivId));
104    }
105    if let Some(pmid) = entry.pubmed_id.as_deref()
106        && let Some(id) = lookup.find_by_pubmed_id(pmid)?
107    {
108        return Ok(MatchOutcome::Matched(id, MatchStrategy::PubmedId));
109    }
110    if let Some(oa) = entry.openalex_id.as_deref()
111        && let Some(id) = lookup.find_by_openalex_id(oa)?
112    {
113        return Ok(MatchOutcome::Matched(id, MatchStrategy::OpenalexId));
114    }
115    // Scitadel-own key: catches export→import round-trip cleanly.
116    if let Some(id) = lookup.find_by_bibtex_key(&entry.citekey)? {
117        return Ok(MatchOutcome::Matched(id, MatchStrategy::BibtexKey));
118    }
119    // Alias: catches re-import of a third-party .bib. Ambiguous
120    // aliases fall through; unambiguous hits resolve.
121    let alias_hits = lookup.find_by_alias(&entry.citekey)?;
122    match alias_hits.len() {
123        0 => {}
124        1 => {
125            return Ok(MatchOutcome::Matched(
126                alias_hits[0].clone(),
127                MatchStrategy::Alias,
128            ));
129        }
130        _ => {
131            // Record the ambiguity so the caller can surface it, then
132            // fall through so the per-paper strategy still runs
133            // title+year and potentially converts the ambiguous alias
134            // into a clean title match.
135            if let Some(title) = entry.title.as_deref()
136                && let Some(id) = lookup.find_by_title_and_year(title, entry.year)?
137            {
138                return Ok(MatchOutcome::Matched(id, MatchStrategy::TitleAndYear));
139            }
140            return Ok(MatchOutcome::AmbiguousAlias(alias_hits));
141        }
142    }
143    if let Some(title) = entry.title.as_deref()
144        && let Some(id) = lookup.find_by_title_and_year(title, entry.year)?
145    {
146        return Ok(MatchOutcome::Matched(id, MatchStrategy::TitleAndYear));
147    }
148    Ok(MatchOutcome::NoMatch)
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use std::cell::RefCell;
155    use std::collections::HashMap;
156
157    #[derive(Default)]
158    struct MockLookup {
159        by_doi: HashMap<String, String>,
160        by_arxiv: HashMap<String, String>,
161        by_pubmed: HashMap<String, String>,
162        by_openalex: HashMap<String, String>,
163        by_bibtex_key: HashMap<String, String>,
164        by_alias: HashMap<String, Vec<String>>,
165        by_title: HashMap<(String, Option<i32>), String>,
166        /// Recorded call order so tests can verify the cascade
167        /// short-circuits on the first hit.
168        call_log: RefCell<Vec<&'static str>>,
169    }
170
171    impl MockLookup {
172        fn log(&self, step: &'static str) {
173            self.call_log.borrow_mut().push(step);
174        }
175    }
176
177    impl PaperLookup for MockLookup {
178        fn find_by_doi(&self, doi: &str) -> Result<Option<String>, CoreError> {
179            self.log("doi");
180            Ok(self.by_doi.get(doi).cloned())
181        }
182        fn find_by_arxiv_id(&self, id: &str) -> Result<Option<String>, CoreError> {
183            self.log("arxiv");
184            Ok(self.by_arxiv.get(id).cloned())
185        }
186        fn find_by_pubmed_id(&self, id: &str) -> Result<Option<String>, CoreError> {
187            self.log("pubmed");
188            Ok(self.by_pubmed.get(id).cloned())
189        }
190        fn find_by_openalex_id(&self, id: &str) -> Result<Option<String>, CoreError> {
191            self.log("openalex");
192            Ok(self.by_openalex.get(id).cloned())
193        }
194        fn find_by_bibtex_key(&self, key: &str) -> Result<Option<String>, CoreError> {
195            self.log("bibtex_key");
196            Ok(self.by_bibtex_key.get(key).cloned())
197        }
198        fn find_by_alias(&self, alias: &str) -> Result<Vec<String>, CoreError> {
199            self.log("alias");
200            Ok(self.by_alias.get(alias).cloned().unwrap_or_default())
201        }
202        fn find_by_title_and_year(
203            &self,
204            title: &str,
205            year: Option<i32>,
206        ) -> Result<Option<String>, CoreError> {
207            self.log("title_year");
208            Ok(self.by_title.get(&(title.to_lowercase(), year)).cloned())
209        }
210    }
211
212    fn bare_entry(citekey: &str) -> BibEntry {
213        BibEntry {
214            citekey: citekey.into(),
215            title: None,
216            authors: vec![],
217            year: None,
218            doi: None,
219            arxiv_id: None,
220            pubmed_id: None,
221            openalex_id: None,
222            note: None,
223            keywords: vec![],
224            file: None,
225            extra: HashMap::default(),
226        }
227    }
228
229    #[test]
230    fn doi_match_short_circuits_before_arxiv() {
231        let mut lookup = MockLookup::default();
232        lookup.by_doi.insert("10.1/x".into(), "p-1".into());
233        // arxiv would also match, but DOI wins the cascade.
234        lookup.by_arxiv.insert("2301.1".into(), "p-2".into());
235
236        let mut e = bare_entry("k");
237        e.doi = Some("10.1/x".into());
238        e.arxiv_id = Some("2301.1".into());
239
240        let out = match_entry(&e, &lookup).unwrap();
241        assert_eq!(out, MatchOutcome::Matched("p-1".into(), MatchStrategy::Doi));
242        assert_eq!(*lookup.call_log.borrow(), vec!["doi"]);
243    }
244
245    #[test]
246    fn cascade_falls_through_doi_arxiv_pubmed_openalex_in_order() {
247        let mut lookup = MockLookup::default();
248        lookup.by_openalex.insert("W1".into(), "p-oa".into());
249
250        let mut e = bare_entry("k");
251        e.doi = Some("nope".into());
252        e.arxiv_id = Some("nope".into());
253        e.pubmed_id = Some("nope".into());
254        e.openalex_id = Some("W1".into());
255
256        let out = match_entry(&e, &lookup).unwrap();
257        assert_eq!(
258            out,
259            MatchOutcome::Matched("p-oa".into(), MatchStrategy::OpenalexId)
260        );
261        assert_eq!(
262            *lookup.call_log.borrow(),
263            vec!["doi", "arxiv", "pubmed", "openalex"]
264        );
265    }
266
267    #[test]
268    fn bibtex_key_catches_round_trip_without_external_ids() {
269        let mut lookup = MockLookup::default();
270        lookup
271            .by_bibtex_key
272            .insert("smith2024quantum".into(), "p-rt".into());
273
274        let e = bare_entry("smith2024quantum");
275        let out = match_entry(&e, &lookup).unwrap();
276        assert_eq!(
277            out,
278            MatchOutcome::Matched("p-rt".into(), MatchStrategy::BibtexKey)
279        );
280    }
281
282    #[test]
283    fn alias_resolves_when_single_hit() {
284        let mut lookup = MockLookup::default();
285        lookup
286            .by_alias
287            .insert("zotero_old_key".into(), vec!["p-aliased".into()]);
288
289        let e = bare_entry("zotero_old_key");
290        let out = match_entry(&e, &lookup).unwrap();
291        assert_eq!(
292            out,
293            MatchOutcome::Matched("p-aliased".into(), MatchStrategy::Alias)
294        );
295    }
296
297    #[test]
298    fn ambiguous_alias_falls_through_to_title_year_when_possible() {
299        let mut lookup = MockLookup::default();
300        lookup
301            .by_alias
302            .insert("smith2024".into(), vec!["p-a".into(), "p-b".into()]);
303        lookup
304            .by_title
305            .insert(("quantum theory".into(), Some(2024)), "p-c".into());
306
307        let mut e = bare_entry("smith2024");
308        e.title = Some("Quantum Theory".into());
309        e.year = Some(2024);
310
311        let out = match_entry(&e, &lookup).unwrap();
312        assert_eq!(
313            out,
314            MatchOutcome::Matched("p-c".into(), MatchStrategy::TitleAndYear)
315        );
316    }
317
318    #[test]
319    fn ambiguous_alias_surfaces_when_no_title_year_rescue() {
320        let mut lookup = MockLookup::default();
321        lookup
322            .by_alias
323            .insert("smith2024".into(), vec!["p-a".into(), "p-b".into()]);
324
325        let e = bare_entry("smith2024");
326        let out = match_entry(&e, &lookup).unwrap();
327        assert_eq!(
328            out,
329            MatchOutcome::AmbiguousAlias(vec!["p-a".into(), "p-b".into()])
330        );
331    }
332
333    #[test]
334    fn no_match_when_nothing_hits() {
335        let lookup = MockLookup::default();
336        let mut e = bare_entry("orphan");
337        e.title = Some("Nothing".into());
338        e.year = Some(1999);
339        assert_eq!(match_entry(&e, &lookup).unwrap(), MatchOutcome::NoMatch);
340    }
341}