use std::collections::HashMap;
use biblatex::{Bibliography, ChunksExt, DateValue, PermissiveType};
#[derive(Debug, thiserror::Error)]
pub enum ParseError {
#[error("biblatex parse failed: {0}")]
Bibliography(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BibEntry {
pub citekey: String,
pub title: Option<String>,
pub authors: Vec<String>,
pub year: Option<i32>,
pub doi: Option<String>,
pub arxiv_id: Option<String>,
pub pubmed_id: Option<String>,
pub openalex_id: Option<String>,
pub note: Option<String>,
pub keywords: Vec<String>,
pub file: Option<String>,
pub extra: HashMap<String, String>,
}
pub fn parse_bibtex(src: &str) -> Result<Vec<BibEntry>, ParseError> {
let bib = Bibliography::parse(src).map_err(|e| ParseError::Bibliography(format!("{e:?}")))?;
Ok(bib.into_vec().into_iter().map(entry_to_bibentry).collect())
}
const EXTRACTED_FIELDS: &[&str] = &[
"title",
"author",
"year",
"date",
"doi",
"eprint",
"eprinttype",
"archiveprefix",
"pmid",
"pubmed",
"openalex",
"note",
"keywords",
"file",
"pdf",
];
fn entry_to_bibentry(e: biblatex::Entry) -> BibEntry {
let citekey = e.key.clone();
let title = e.title().ok().map(|chunks| chunks.format_verbatim());
let authors = e
.author()
.ok()
.map(|people| people.iter().map(person_to_string).collect())
.unwrap_or_default();
let year = extract_year(&e);
let doi = e.doi().ok().map(|s| normalize_doi(&s));
let arxiv_id = extract_arxiv_id(&e);
let pubmed_id = extract_raw(&e, "pmid")
.or_else(|| extract_raw(&e, "pubmed"))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let openalex_id = extract_raw(&e, "openalex")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let note = e.note().ok().map(|chunks| chunks.format_verbatim());
let keywords = e
.keywords()
.ok()
.map(|chunks| {
chunks
.format_verbatim()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
})
.unwrap_or_default();
let file = e.file().ok();
let extra: HashMap<String, String> = e
.fields
.iter()
.filter(|(k, _)| !EXTRACTED_FIELDS.contains(&k.as_str()))
.map(|(k, v)| (k.clone(), v.format_verbatim()))
.collect();
BibEntry {
citekey,
title,
authors,
year,
doi,
arxiv_id,
pubmed_id,
openalex_id,
note,
keywords,
file,
extra,
}
}
fn person_to_string(p: &biblatex::Person) -> String {
let family = p.name.trim();
let given = p.given_name.trim();
let prefix = p.prefix.trim();
let suffix = p.suffix.trim();
let family_full = if prefix.is_empty() {
family.to_string()
} else {
format!("{prefix} {family}")
};
let family_suffixed = if suffix.is_empty() {
family_full
} else {
format!("{family_full}, {suffix}")
};
if given.is_empty() {
family_suffixed
} else {
format!("{family_suffixed}, {given}")
}
}
fn extract_year(e: &biblatex::Entry) -> Option<i32> {
if let Ok(date) = e.date()
&& let PermissiveType::Typed(d) = date
{
let y = match d.value {
DateValue::At(dt) | DateValue::After(dt) | DateValue::Before(dt) => dt.year,
DateValue::Between(start, _) => start.year,
};
return Some(y);
}
let raw = extract_raw(e, "year")?;
let digits: String = raw.chars().filter(char::is_ascii_digit).take(4).collect();
digits.parse().ok()
}
fn extract_arxiv_id(e: &biblatex::Entry) -> Option<String> {
let eprint = e.eprint().ok()?;
let eprint_type = e
.eprint_type()
.ok()
.map(|chunks| chunks.format_verbatim().to_lowercase())
.unwrap_or_default();
if eprint_type == "arxiv" || eprint_type.is_empty() {
let trimmed = eprint.trim().to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
None
}
fn extract_raw(e: &biblatex::Entry, field: &str) -> Option<String> {
e.fields.get(field).map(|chunks| chunks.format_verbatim())
}
pub fn normalize_doi(raw: &str) -> String {
let trimmed = raw.trim();
let no_proto = trimmed
.strip_prefix("https://doi.org/")
.or_else(|| trimmed.strip_prefix("http://doi.org/"))
.or_else(|| trimmed.strip_prefix("doi:"))
.unwrap_or(trimmed);
no_proto.to_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_canonical_article() {
let src = r"
@article{smith2024quantum,
title = {Quantum Advantage},
author = {Smith, John and Doe, Jane},
year = {2024},
journal = {Nature},
doi = {10.1038/ABC.2024.001}
}";
let entries = parse_bibtex(src).unwrap();
assert_eq!(entries.len(), 1);
let e = &entries[0];
assert_eq!(e.citekey, "smith2024quantum");
assert_eq!(e.title.as_deref(), Some("Quantum Advantage"));
assert_eq!(e.authors, vec!["Smith, John", "Doe, Jane"]);
assert_eq!(e.year, Some(2024));
assert_eq!(e.doi.as_deref(), Some("10.1038/abc.2024.001"));
}
#[test]
fn normalizes_doi_prefix_and_case() {
let cases = [
("10.1038/NATURE.2024", "10.1038/nature.2024"),
("https://doi.org/10.1038/X", "10.1038/x"),
("http://doi.org/10.1038/y", "10.1038/y"),
("doi:10.1038/z", "10.1038/z"),
(" 10.1038/TRIM ", "10.1038/trim"),
];
for (raw, want) in cases {
assert_eq!(normalize_doi(raw), want, "input={raw}");
}
}
#[test]
fn extracts_arxiv_id_from_eprint_with_archiveprefix() {
let src = r"
@article{a,
title = {T},
eprint = {2301.00001},
archivePrefix = {arXiv}
}";
let e = &parse_bibtex(src).unwrap()[0];
assert_eq!(e.arxiv_id.as_deref(), Some("2301.00001"));
}
#[test]
fn extracts_arxiv_id_from_eprint_with_eprinttype() {
let src = r"
@article{a,
title = {T},
eprint = {2301.99999},
eprinttype = {arxiv}
}";
let e = &parse_bibtex(src).unwrap()[0];
assert_eq!(e.arxiv_id.as_deref(), Some("2301.99999"));
}
#[test]
fn extracts_pubmed_and_openalex_ids() {
let src = r"
@article{a,
title = {T},
pmid = {12345678},
openalex = {W1234567890}
}";
let e = &parse_bibtex(src).unwrap()[0];
assert_eq!(e.pubmed_id.as_deref(), Some("12345678"));
assert_eq!(e.openalex_id.as_deref(), Some("W1234567890"));
}
#[test]
fn parses_zotero_note_keywords_file() {
let src = r"
@article{z,
title = {T},
note = {Read twice, felt it},
keywords = {alpha, beta, gamma},
file = {/path/to/paper.pdf}
}";
let e = &parse_bibtex(src).unwrap()[0];
assert_eq!(e.note.as_deref(), Some("Read twice, felt it"));
assert_eq!(e.keywords, vec!["alpha", "beta", "gamma"]);
assert!(e.file.as_deref().unwrap().ends_with("paper.pdf"));
}
#[test]
fn year_falls_back_from_biblatex_date() {
let src = r"
@article{d,
title = {T},
date = {2023-05-01}
}";
let e = &parse_bibtex(src).unwrap()[0];
assert_eq!(e.year, Some(2023));
}
#[test]
fn empty_source_yields_empty_vec() {
assert!(parse_bibtex("").unwrap().is_empty());
assert!(parse_bibtex(" \n\n ").unwrap().is_empty());
}
#[test]
fn extra_fields_preserved_for_merge_strategy() {
let src = r"
@article{a,
title = {T},
author = {X, Y},
publisher = {Elsevier},
month = {may}
}";
let e = &parse_bibtex(src).unwrap()[0];
assert_eq!(
e.extra.get("publisher").map(String::as_str),
Some("Elsevier")
);
assert_eq!(e.extra.get("month").map(String::as_str), Some("may"));
assert!(
!e.extra.contains_key("title"),
"title is extracted, not extra"
);
}
#[test]
fn author_person_prefix_and_suffix_rendered() {
let src = r"
@article{v,
title = {T},
author = {von Neumann, Jr., John}
}";
let e = &parse_bibtex(src).unwrap()[0];
assert_eq!(e.authors, vec!["von Neumann, Jr., John"]);
}
}