1use serde::Serialize;
16
17use super::Metadata;
18
19#[must_use]
38pub fn to_bibtex(citation_key: &str, m: &Metadata) -> String {
39 to_bibtex_with_fields(citation_key, m, &[])
40}
41
42#[must_use]
46pub fn to_bibtex_with_fields(citation_key: &str, m: &Metadata, extra: &[(&str, &str)]) -> String {
47 let mut out = String::new();
48 let entry_type = bibtex_entry_type(m.type_.as_deref());
49 out.push_str(&format!("@{entry_type}{{{citation_key},\n"));
50
51 push_field(&mut out, "title", &m.title);
52 if !m.authors.is_empty() {
53 push_field(&mut out, "author", &m.authors.join(" and "));
55 }
56 if let Some(year) = m.year {
57 push_field(&mut out, "year", &year.to_string());
58 }
59 if let Some(doi) = &m.doi {
60 push_field(&mut out, "doi", doi.as_str());
61 }
62 if let Some(venue) = m.venue.as_deref() {
63 if !venue.is_empty() {
64 push_field(&mut out, "journal", venue);
65 }
66 }
67 if let Some(volume) = m.volume.as_deref() {
68 if !volume.is_empty() {
69 push_field(&mut out, "volume", volume);
70 }
71 }
72 if let Some(issue) = m.issue.as_deref() {
74 if !issue.is_empty() {
75 push_field(&mut out, "number", issue);
76 }
77 }
78 if let Some(pages) = m.pages.as_deref() {
79 if !pages.is_empty() {
80 push_field(&mut out, "pages", pages);
81 }
82 }
83 if let Some(publisher) = m.publisher.as_deref() {
84 if !publisher.is_empty() {
85 push_field(&mut out, "publisher", publisher);
86 }
87 }
88 if let Some(issn) = m.issn.as_deref() {
89 if !issn.is_empty() {
90 push_field(&mut out, "issn", issn);
91 }
92 }
93 if entry_type == "software" {
96 if let Some(v) = m.other.get("version").and_then(toml::Value::as_str) {
97 push_field(&mut out, "version", v);
98 }
99 if let Some(u) = m.url.as_deref().filter(|u| !u.is_empty()) {
100 push_field(&mut out, "url", u);
101 }
102 }
103
104 if let Some(arxiv_id) = &m.arxiv_id {
111 push_field(&mut out, "eprint", arxiv_id.as_str());
112 push_field(&mut out, "archivePrefix", "arXiv");
113 if let Some(class) = arxiv_primary_class(m) {
114 push_field(&mut out, "primaryClass", &class);
115 }
116 }
117 for (name, value) in extra {
118 push_field(&mut out, name, value);
119 }
120
121 out.push_str("}\n");
122 out
123}
124
125fn arxiv_primary_class(m: &Metadata) -> Option<String> {
133 if let Some(first) = m.arxiv_categories.first() {
134 return Some(first.clone());
135 }
136 m.arxiv_id.as_ref().and_then(|id| {
137 id.as_str()
138 .split_once('/')
139 .map(|(archive, _)| archive.to_string())
140 })
141}
142
143fn bibtex_entry_type(type_: Option<&str>) -> &'static str {
148 match type_ {
149 Some("journal-article") => "article",
150 Some("software" | "Software") => "software",
154 _ => "misc",
155 }
156}
157
158fn push_field(out: &mut String, key: &str, value: &str) {
163 let escaped = strip_bibtex_unsafe(key, value);
164 out.push_str(&format!(" {key:<10} = {{{escaped}}},\n"));
165}
166
167fn strip_bibtex_unsafe(key: &str, value: &str) -> String {
178 let detagged = crate::markup::plain_title(value);
179 if detagged.contains('{') || detagged.contains('}') {
180 tracing::warn!(
181 field = key,
182 "stripping literal '{{'/'}}' from BibTeX field value; \
183 a TeX-aware escaper lands in a Phase 2 follow-up"
184 );
185 }
186 detagged
187 .chars()
188 .filter(|c| !matches!(c, '{' | '}'))
189 .collect()
190}
191
192#[must_use]
204pub fn to_csl_array(citation_key: &str, m: &Metadata) -> serde_json::Value {
205 to_csl_array_with(citation_key, m, false)
206}
207
208#[must_use]
212pub fn to_csl_array_with(
213 citation_key: &str,
214 m: &Metadata,
215 short_container: bool,
216) -> serde_json::Value {
217 let mut item = build_csl_item(citation_key, m);
218 if short_container {
219 item.container_title_short = m.doiget.as_ref().and_then(|d| d.short_venue.clone());
220 }
221 serde_json::to_value([item]).unwrap_or_else(|_| serde_json::Value::Array(Vec::new()))
225}
226
227#[derive(Debug, Serialize)]
232struct CslItem<'a> {
233 id: &'a str,
234 #[serde(rename = "type")]
235 type_: &'static str,
236 title: String,
237 #[serde(skip_serializing_if = "Vec::is_empty")]
238 author: Vec<CslName>,
239 #[serde(skip_serializing_if = "Option::is_none")]
240 issued: Option<CslIssued>,
241 #[serde(rename = "DOI", skip_serializing_if = "Option::is_none")]
242 doi: Option<&'a str>,
243 #[serde(rename = "container-title", skip_serializing_if = "Option::is_none")]
244 container_title: Option<String>,
245 #[serde(
246 rename = "container-title-short",
247 skip_serializing_if = "Option::is_none"
248 )]
249 container_title_short: Option<String>,
250 #[serde(skip_serializing_if = "Option::is_none")]
251 volume: Option<&'a str>,
252 #[serde(skip_serializing_if = "Option::is_none")]
253 issue: Option<&'a str>,
254 #[serde(skip_serializing_if = "Option::is_none")]
255 page: Option<&'a str>,
256 #[serde(skip_serializing_if = "Option::is_none")]
257 publisher: Option<&'a str>,
258 #[serde(rename = "ISSN", skip_serializing_if = "Option::is_none")]
259 issn: Option<&'a str>,
260 #[serde(skip_serializing_if = "Option::is_none")]
262 version: Option<&'a str>,
263 #[serde(rename = "URL", skip_serializing_if = "Option::is_none")]
265 url: Option<&'a str>,
266}
267
268#[derive(Debug, Serialize)]
271struct CslName {
272 #[serde(skip_serializing_if = "String::is_empty")]
273 family: String,
274 #[serde(skip_serializing_if = "String::is_empty")]
275 given: String,
276}
277
278#[derive(Debug, Serialize)]
281struct CslIssued {
282 #[serde(rename = "date-parts")]
283 date_parts: Vec<Vec<i32>>,
284}
285
286fn build_csl_item<'a>(citation_key: &'a str, m: &'a Metadata) -> CslItem<'a> {
287 let software = matches!(m.type_.as_deref(), Some("software" | "Software"));
288 CslItem {
289 id: citation_key,
290 type_: match m.type_.as_deref() {
291 Some("journal-article") => "article-journal",
292 _ if software => "software",
294 _ => "manuscript",
295 },
296 title: crate::markup::plain_title(&m.title),
297 author: m.authors.iter().map(|s| parse_author(s)).collect(),
298 issued: m.year.map(|y| CslIssued {
299 date_parts: vec![vec![y]],
300 }),
301 doi: m.doi.as_ref().map(|d| d.as_str()),
302 container_title: m.venue.as_deref().map(crate::markup::plain_title),
303 container_title_short: None,
304 volume: m.volume.as_deref(),
305 issue: m.issue.as_deref(),
306 page: m.pages.as_deref(),
307 publisher: m.publisher.as_deref(),
308 issn: m.issn.as_deref(),
309 version: m
310 .other
311 .get("version")
312 .and_then(toml::Value::as_str)
313 .filter(|_| software),
314 url: m.url.as_deref().filter(|_| software),
315 }
316}
317
318fn parse_author(name: &str) -> CslName {
326 let trimmed = name.trim();
327 if let Some((family, given)) = trimmed.split_once(',') {
328 CslName {
329 family: family.trim().to_string(),
330 given: given.trim().to_string(),
331 }
332 } else if let Some(idx) = trimmed.rfind(char::is_whitespace) {
333 let (given, family) = trimmed.split_at(idx);
334 CslName {
335 family: family.trim().to_string(),
336 given: given.trim().to_string(),
337 }
338 } else {
339 CslName {
340 family: trimmed.to_string(),
341 given: String::new(),
342 }
343 }
344}
345
346#[cfg(test)]
351#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
352mod tests {
353 use std::collections::BTreeMap;
354
355 use chrono::TimeZone;
356
357 use super::*;
358 use crate::store::{DoigetExtension, Metadata};
359 use crate::{Doi, SCHEMA_VERSION};
360
361 fn fixture(type_: Option<&str>) -> Metadata {
362 Metadata {
363 schema_version: SCHEMA_VERSION.to_string(),
364 title: "Quantum Stuff".to_string(),
365 authors: vec!["Alice Researcher".to_string(), "Bob Coauthor".to_string()],
366 year: Some(2026),
367 doi: Some(Doi::parse("10.1234/example").expect("valid DOI")),
368 arxiv_id: None,
369 arxiv_categories: vec![],
370 abstract_: None,
371 venue: Some("Phys Rev X".to_string()),
372 volume: Some("12".to_string()),
373 issue: Some("3".to_string()),
374 pages: Some("031001".to_string()),
375 publisher: Some("APS".to_string()),
376 issn: Some("2160-3308".to_string()),
377 isbn: None,
378 type_: type_.map(str::to_string),
379 keywords: vec![],
380 url: None,
381 pdf_path: None,
382 doiget: Some(DoigetExtension {
383 fetched_at: chrono::Utc
384 .with_ymd_and_hms(2026, 5, 6, 12, 0, 0)
385 .single()
386 .expect("valid timestamp"),
387 source: "unpaywall".to_string(),
388 license: "CC-BY-4.0".to_string(),
389 oa_status: None,
390 size_bytes: 1234,
391 mcp_call_id: None,
392 tags: Vec::new(),
393 collections: Vec::new(),
394 annotation: None,
395 repaired_fields: Default::default(),
396 short_venue: None,
397 origin: None,
398 }),
399 other: BTreeMap::new(),
400 }
401 }
402
403 #[test]
406 fn bibtex_journal_article_renders_as_article() {
407 let s = to_bibtex("doi_10.1234_example", &fixture(Some("journal-article")));
408 assert!(s.starts_with("@article{doi_10.1234_example,\n"), "{s}");
409 assert!(s.contains("title = {Quantum Stuff},"), "{s}");
410 assert!(
411 s.contains("author = {Alice Researcher and Bob Coauthor},"),
412 "{s}"
413 );
414 assert!(s.contains("year = {2026},"), "{s}");
415 assert!(s.contains("doi = {10.1234/example},"), "{s}");
416 assert!(s.contains("journal = {Phys Rev X},"), "{s}");
417 assert!(s.contains("volume = {12},"), "{s}");
418 assert!(s.contains("number = {3},"), "{s}");
419 assert!(s.contains("pages = {031001},"), "{s}");
420 assert!(s.contains("publisher = {APS},"), "{s}");
421 assert!(s.contains("issn = {2160-3308},"), "{s}");
422 assert!(s.ends_with("}\n"), "{s}");
423 }
424
425 #[test]
426 fn bibtex_missing_and_unknown_type_render_as_misc() {
427 assert!(to_bibtex("k", &fixture(None)).starts_with("@misc{k,\n"));
428 assert!(to_bibtex("k", &fixture(Some("posted-content"))).starts_with("@misc{k,\n"));
429 }
430
431 #[test]
432 fn bibtex_arxiv_emits_eprint_archiveprefix_primaryclass() {
433 let mut m = fixture(None);
437 m.doi = None;
438 m.venue = None;
439 m.volume = None;
440 m.issue = None;
441 m.pages = None;
442 m.publisher = None;
443 m.issn = None;
444 m.arxiv_id = Some(crate::ArxivId::parse("2012.03644").expect("valid id"));
445 m.arxiv_categories = vec!["cond-mat.str-el".to_string(), "cond-mat.dis-nn".to_string()];
446 let s = to_bibtex("arxiv_2012.03644", &m);
447 assert!(s.starts_with("@misc{arxiv_2012.03644,\n"), "{s}");
448 assert!(s.contains("archivePrefix = {arXiv},"), "{s}");
450 assert!(s.contains("primaryClass = {cond-mat.str-el},"), "{s}");
451 assert!(s.contains("eprint") && s.contains("= {2012.03644},"), "{s}");
453 assert!(s.contains("year = {2026},"), "{s}");
455 }
456
457 #[test]
458 fn bibtex_arxiv_old_style_id_primaryclass_from_prefix() {
459 let mut m = fixture(None);
462 m.doi = None;
463 m.arxiv_id = Some(crate::ArxivId::parse("cond-mat/0403602").expect("valid id"));
464 m.arxiv_categories = vec![];
465 let s = to_bibtex("k", &m);
466 assert!(s.contains("= {cond-mat/0403602},"), "{s}");
467 assert!(s.contains("primaryClass = {cond-mat},"), "{s}");
468 }
469
470 #[test]
471 fn bibtex_non_arxiv_omits_eprint() {
472 let s = to_bibtex("k", &fixture(Some("journal-article")));
474 assert!(!s.contains("eprint"), "{s}");
475 assert!(!s.contains("archivePrefix"), "{s}");
476 }
477
478 #[test]
479 fn bibtex_empty_optionals_omitted() {
480 let mut m = fixture(Some("journal-article"));
481 m.venue = None;
482 m.volume = None;
483 m.issue = None;
484 m.pages = None;
485 m.publisher = None;
486 m.issn = None;
487 let s = to_bibtex("k", &m);
488 assert!(!s.contains("journal"), "{s}");
489 assert!(!s.contains("volume"), "{s}");
490 assert!(!s.contains("number"), "{s}");
491 assert!(!s.contains("pages"), "{s}");
492 assert!(!s.contains("publisher"), "{s}");
493 assert!(!s.contains("issn"), "{s}");
494 assert!(s.contains("title") && s.contains("author") && s.contains("year"));
495 }
496
497 #[test]
498 fn bibtex_no_authors_omits_author_line() {
499 let mut m = fixture(Some("journal-article"));
500 m.authors = vec![];
501 assert!(!to_bibtex("k", &m).contains("author"));
502 }
503
504 #[test]
505 fn bibtex_braces_stripped() {
506 let mut m = fixture(Some("journal-article"));
507 m.title = "A {curly} Title".to_string();
508 assert!(to_bibtex("k", &m).contains("title = {A curly Title},"));
509 }
510
511 #[test]
512 fn bibtex_html_mathml_tags_stripped() {
513 let mut m = fixture(Some("journal-article"));
514 m.title = "Spin-<i>S</i> chains with <mml:math><mml:mi>S</mml:mi>\
516 </mml:math>=1 order"
517 .to_string();
518 let s = to_bibtex("k", &m);
519 assert!(
520 s.contains("title = {Spin-S chains with S=1 order},"),
521 "{s}"
522 );
523 }
524
525 #[test]
526 fn csl_title_and_container_markup_is_reduced_to_text() {
527 let mut m = fixture(Some("journal-article"));
529 m.title = "Spin-<i>S</i> chains".to_string();
530 m.venue = Some("J. <i>Chem</i>. Phys.".to_string());
531 let v = to_csl_array("k", &m);
532 assert_eq!(v[0]["title"], "Spin-S chains");
533 assert_eq!(v[0]["container-title"], "J. Chem. Phys.");
534 }
535
536 #[test]
537 fn csl_container_title_short_only_on_request_and_only_when_known() {
538 let mut m = fixture(Some("journal-article"));
539 assert!(to_csl_array_with("k", &m, true)[0]
540 .get("container-title-short")
541 .is_none());
542 m.doiget.as_mut().expect("ext").short_venue = Some("Phys. Rev. X".into());
543 assert!(to_csl_array("k", &m)[0]
544 .get("container-title-short")
545 .is_none());
546 assert_eq!(
547 to_csl_array_with("k", &m, true)[0]["container-title-short"],
548 "Phys. Rev. X"
549 );
550 }
551
552 #[test]
553 fn bibtex_an_inequality_in_a_title_is_not_a_tag() {
554 let mut m = fixture(Some("journal-article"));
557 m.title = "Resistivity for T<Tc in field H>Hc2".to_string();
558 assert!(to_bibtex("k", &m).contains("title = {Resistivity for T<Tc in field H>Hc2},"));
559 }
560
561 #[test]
562 fn bibtex_unescaped_lt_without_close_is_preserved() {
563 let mut m = fixture(Some("journal-article"));
566 m.title = "Regime a < b holds".to_string();
567 assert!(to_bibtex("k", &m).contains("title = {Regime a < b holds},"));
568 }
569
570 #[test]
573 fn csl_array_shape_and_fields() {
574 let v = to_csl_array("doi_10.1234_example", &fixture(Some("journal-article")));
575 let arr = v.as_array().expect("CSL output is an array");
576 assert_eq!(arr.len(), 1);
577 let it = &arr[0];
578 assert_eq!(it["id"], "doi_10.1234_example");
579 assert_eq!(it["type"], "article-journal");
580 assert_eq!(it["title"], "Quantum Stuff");
581 assert_eq!(it["DOI"], "10.1234/example");
582 assert_eq!(it["container-title"], "Phys Rev X");
583 assert_eq!(it["volume"], "12");
584 assert_eq!(it["issue"], "3");
585 assert_eq!(it["page"], "031001");
586 assert_eq!(it["ISSN"], "2160-3308");
587 assert_eq!(it["issued"]["date-parts"][0][0], 2026);
588 assert_eq!(it["author"][0]["family"], "Researcher");
589 assert_eq!(it["author"][0]["given"], "Alice");
590 }
591
592 #[test]
593 fn csl_unknown_type_is_manuscript() {
594 let v = to_csl_array("k", &fixture(None));
595 assert_eq!(v.as_array().unwrap()[0]["type"], "manuscript");
596 }
597
598 #[test]
599 fn csl_comma_name_split() {
600 let mut m = fixture(Some("journal-article"));
601 m.authors = vec!["Curie, Marie".to_string(), "Plato".to_string()];
602 let v = to_csl_array("k", &m);
603 let authors = v.as_array().unwrap()[0]["author"]
604 .as_array()
605 .unwrap()
606 .clone();
607 assert_eq!(authors[0]["family"], "Curie");
608 assert_eq!(authors[0]["given"], "Marie");
609 assert_eq!(authors[1]["family"], "Plato");
610 assert!(
611 authors[1].get("given").is_none(),
612 "single-token name has no given"
613 );
614 }
615}