use scitadel_core::bibtex_key::{assign_keys, generate_key};
use scitadel_core::models::Paper;
use std::collections::HashSet;
pub use scitadel_core::bibtex_key;
pub const KEY_ALGO_HASH: &str = "1a7e0b9c2f8d6a4b3e5c9d8f7b6a5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b";
#[must_use]
pub fn export_bibtex(papers: &[Paper]) -> String {
let mut owned: Vec<(String, &Paper)> = papers
.iter()
.map(|p| {
let key = p.bibtex_key.clone().unwrap_or_else(|| generate_key(p));
(key, p)
})
.collect();
owned.sort_by(|a, b| a.0.cmp(&b.0));
let mut out = String::new();
out.push_str("% scitadel ");
out.push_str(env!("CARGO_PKG_VERSION"));
out.push_str(" · algo_hash=");
out.push_str(KEY_ALGO_HASH);
out.push('\n');
out.push('\n');
for (i, (key, paper)) in owned.iter().enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str(&paper_to_entry(key, paper));
}
out
}
pub fn backfill_keys<S: std::hash::BuildHasher>(
papers: &mut [Paper],
taken: &mut HashSet<String, S>,
) -> Vec<(String, String)> {
let needs_key: Vec<usize> = papers
.iter()
.enumerate()
.filter(|(_, p)| p.bibtex_key.is_none())
.map(|(i, _)| i)
.collect();
if needs_key.is_empty() {
return Vec::new();
}
let slice: Vec<Paper> = needs_key.iter().map(|&i| papers[i].clone()).collect();
let keys = assign_keys(&slice, taken);
let mut out = Vec::with_capacity(keys.len());
for (idx, key) in needs_key.iter().zip(keys) {
papers[*idx].bibtex_key = Some(key.clone());
out.push((papers[*idx].id.as_str().to_string(), key));
}
out
}
fn paper_to_entry(key: &str, paper: &Paper) -> String {
let mut fields: Vec<String> = Vec::new();
fields.push(fmt_field("title", &paper.title));
if !paper.authors.is_empty() {
fields.push(fmt_field("author", &paper.authors.join(" and ")));
}
if let Some(y) = paper.year {
fields.push(format!(" year = {{{y}}}"));
}
if let Some(j) = &paper.journal {
fields.push(fmt_field("journal", j));
}
if let Some(d) = &paper.doi {
fields.push(fmt_field("doi", d));
}
if let Some(u) = &paper.url {
fields.push(fmt_field("url", u));
}
if let Some(a) = &paper.arxiv_id {
fields.push(fmt_field("eprint", a));
fields.push(" eprinttype = {arxiv}".to_string());
}
if !paper.r#abstract.is_empty() {
fields.push(fmt_field("abstract", &paper.r#abstract));
}
format!("@article{{{key},\n{}\n}}\n", fields.join(",\n"))
}
fn fmt_field(name: &str, value: &str) -> String {
format!(" {name} = {{{}}}", escape_bibtex(value))
}
fn escape_bibtex(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("\\&"),
'%' => out.push_str("\\%"),
'#' => out.push_str("\\#"),
_ => out.push(ch),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use scitadel_core::models::PaperId;
fn paper(title: &str, authors: &[&str], year: Option<i32>) -> Paper {
let mut p = Paper::new(title);
p.authors = authors.iter().map(|s| (*s).to_string()).collect();
p.year = year;
p
}
#[test]
fn export_sorts_by_key_and_is_deterministic() {
let p1 = {
let mut p = paper("Zebra", &["Zed"], Some(2024));
p.bibtex_key = Some("zed2024zebra".into());
p
};
let p2 = {
let mut p = paper("Apple", &["Aaron"], Some(2024));
p.bibtex_key = Some("aaron2024apple".into());
p
};
let out1 = export_bibtex(&[p1.clone(), p2.clone()]);
let out2 = export_bibtex(&[p2.clone(), p1.clone()]);
assert_eq!(out1, out2, "input order must not affect output");
let aaron_pos = out1.find("aaron2024apple").unwrap();
let zed_pos = out1.find("zed2024zebra").unwrap();
assert!(aaron_pos < zed_pos, "alphabetical by key");
}
#[test]
fn export_escapes_ampersand_and_percent() {
let mut p = paper("Cats & Dogs: a 100% study", &["Smith"], Some(2024));
p.bibtex_key = Some("smith2024cats".into());
let out = export_bibtex(&[p]);
assert!(out.contains("Cats \\& Dogs"), "got: {out}");
assert!(out.contains("100\\% study"));
}
#[test]
fn export_preserves_unicode_literally() {
let mut p = paper("Über quantum", &["Müller"], Some(2024));
p.bibtex_key = Some("muller2024uber".into());
let out = export_bibtex(&[p]);
assert!(out.contains("Über quantum"));
assert!(out.contains("Müller"));
}
#[test]
fn export_has_header_without_timestamp() {
let mut p = paper("Test", &["Anon"], Some(2024));
p.bibtex_key = Some("anon2024test".into());
let out = export_bibtex(&[p]);
assert!(out.starts_with("% scitadel"));
assert!(out.contains("algo_hash="));
assert!(!out.contains("generated at"));
}
#[test]
fn export_uses_lf_not_crlf() {
let mut p = paper("Test", &["Anon"], Some(2024));
p.bibtex_key = Some("anon2024test".into());
let out = export_bibtex(&[p]);
assert!(!out.contains('\r'), "force LF; no CR anywhere");
}
#[test]
fn backfill_assigns_keys_in_uuid_order() {
let mut a = paper("Same", &["Dup"], Some(2024));
let mut b = paper("Same", &["Dup"], Some(2024));
a.id = PaperId::from("aaaaaaaa");
b.id = PaperId::from("bbbbbbbb");
let mut papers = vec![a, b];
let mut taken = HashSet::new();
let assignments = backfill_keys(&mut papers, &mut taken);
assert_eq!(assignments.len(), 2);
assert_eq!(papers[0].bibtex_key.as_deref(), Some("dup2024same"));
assert_eq!(papers[1].bibtex_key.as_deref(), Some("dup2024samea"));
}
#[test]
fn backfill_skips_already_keyed_papers() {
let mut p = paper("Test", &["Anon"], Some(2024));
p.bibtex_key = Some("pinned-key".into());
let mut papers = vec![p];
let mut taken = HashSet::new();
let assignments = backfill_keys(&mut papers, &mut taken);
assert!(assignments.is_empty());
assert_eq!(papers[0].bibtex_key.as_deref(), Some("pinned-key"));
}
#[test]
fn key_algo_hash_is_frozen() {
let cases: &[(&str, &[&str], Option<i32>, &str)] = &[
(
"Machine Learning for Science",
&["Smith, John"],
Some(2024),
"smith2024machine",
),
(
"The Transformer Architecture",
&["Vaswani, A"],
Some(2017),
"vaswani2017transformer",
),
(
"Quantum Computing",
&["Müller, Hans"],
Some(2023),
"muller2023quantum",
),
(
"Deep Residual Learning",
&["Kaiming He"],
Some(2015),
"he2015deep",
),
];
for (title, authors, year, want) in cases {
let p = paper(title, authors, *year);
let got = generate_key(&p);
assert_eq!(got, *want, "title={title}");
}
}
}