1use super::parse::BibEntry;
26use scitadel_core::error::CoreError;
27
28pub 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 fn find_by_bibtex_key(&self, key: &str) -> Result<Option<String>, CoreError>;
39 fn find_by_alias(&self, alias: &str) -> Result<Vec<String>, CoreError>;
42 fn find_by_title_and_year(
44 &self,
45 title: &str,
46 year: Option<i32>,
47 ) -> Result<Option<String>, CoreError>;
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum MatchOutcome {
56 Matched(String, MatchStrategy),
60 NoMatch,
62 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
93pub 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 if let Some(id) = lookup.find_by_bibtex_key(&entry.citekey)? {
117 return Ok(MatchOutcome::Matched(id, MatchStrategy::BibtexKey));
118 }
119 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 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 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 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}