Skip to main content

doiget_core/store/
render.rs

1//! Citation renderers for stored [`Metadata`] — BibTeX and CSL JSON 1.0.
2//!
3//! Phase 2 / Slice 15b. The rendering logic originally lived in the
4//! `doiget-cli` `bib` / `csl` subcommands; it is hoisted here so the
5//! `doiget-mcp` `doiget_bibtex_export` / `doiget_csl_export` tools and
6//! the CLI share a single implementation (`docs/MCP_TOOLS.md` §1 rows
7//! `doiget_bibtex_export` / `doiget_csl_export`).
8//!
9//! Both renderers are pure functions of a [`Metadata`] plus a citation
10//! key (the entry's safekey). No I/O, no network. They emit the Phase 1
11//! binding fields from `docs/STORE.md` §2 (title, authors, year, doi,
12//! venue, publisher, issn); richer entry-type / field mapping is a
13//! Phase 2 follow-up.
14
15use serde::Serialize;
16
17use super::Metadata;
18
19// ---------------------------------------------------------------------------
20// BibTeX
21// ---------------------------------------------------------------------------
22
23/// Render a single BibTeX entry for `m`, keyed by `citation_key`.
24///
25/// `journal-article` → `@article`; everything else → `@misc` (Phase 2
26/// starter — `@inproceedings` / `@book` mapping is a follow-up). Field
27/// order: `title`, `author`, `year`, `doi`, `journal`, `volume`,
28/// `number`, `pages`, `publisher`, `issn`, then — when the entry carries
29/// an arXiv id — `eprint`, `archivePrefix`, `primaryClass` (issue #303).
30/// Any empty / `None` field is omitted. The returned string is a complete
31/// entry terminated by `}\n`.
32///
33/// Literal `{` / `}` in a field value would unbalance the surrounding
34/// braces; they are stripped (with a `tracing::warn!`) rather than
35/// TeX-escaped — real-world Crossref / Unpaywall titles rarely contain
36/// bare braces, so this is safe-by-default for the Phase 2 starter.
37#[must_use]
38pub fn to_bibtex(citation_key: &str, m: &Metadata) -> String {
39    to_bibtex_with_fields(citation_key, m, &[])
40}
41
42/// [`to_bibtex`] with `extra` fields appended after the ones the metadata
43/// supplies, in order -- e.g. `("file", "refs/fock1930.pdf")` (#610).
44/// Values get the same brace and markup scrubbing as every other field.
45#[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        // BibTeX joins multiple authors with the literal token " and ".
54        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    // BibTeX names the issue field `number`.
73    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    // Software (#614): the version cited and where it lives. A release has
94    // no journal to find it by, so the URL is the locator.
95    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    // arXiv preprint identity (issue #303): emit `eprint` + `archivePrefix`
105    // (+ `primaryClass` when known) for any entry carrying an arXiv id, so
106    // the reference resolves on arXiv and in reference managers instead of
107    // reading as a title+author stub. Standard arXiv BibTeX convention;
108    // applies to both the `@misc` preprint and a `@article` that also has a
109    // preprint.
110    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
125/// The arXiv primary subject class for a BibTeX `primaryClass` field.
126///
127/// Prefers the parsed Atom category (`Metadata::arxiv_categories[0]`, the
128/// only source for a new-style id like `2012.03644`). Falls back to the
129/// archive prefix embedded in an **old-style** id (`cond-mat/0403602` →
130/// `cond-mat`); a new-style id with no stored categories yields `None`, so
131/// the field is honestly omitted rather than guessed.
132fn 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
143/// Map a Crossref-taxonomy `type` string to a BibTeX entry type.
144///
145/// Phase 2 starter only differentiates `journal-article` (→ `article`)
146/// from everything else (→ `misc`).
147fn bibtex_entry_type(type_: Option<&str>) -> &'static str {
148    match type_ {
149        Some("journal-article") => "article",
150        // Our own GitHub record, and DataCite's resourceTypeGeneral (#614).
151        // biblatex has `@software`; classic BibTeX styles treat an unknown
152        // type as `@misc`, so nothing is lost there.
153        Some("software" | "Software") => "software",
154        _ => "misc",
155    }
156}
157
158/// Append a single `  <key>      = {<value>},\n` line, padded so the `=`
159/// columns line up for the standard fields (width 10 fits `publisher`).
160/// A longer optional key -- `shortjournal` (#611), `archivePrefix` --
161/// overruns the column rather than re-indenting every entry's output.
162fn 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
167/// Strip BibTeX-unsafe `{` / `}` from `value`, warning once per field so
168/// the dropped characters are visible in stderr / structured logs.
169///
170/// Crossref embeds JATS / HTML / MathML markup in titles and venues (`<i>`,
171/// `<sub>`, `<mml:math>…</mml:math>`), often pretty-printed onto lines of
172/// their own. [`crate::markup::plain_title`] reduces that to the text the
173/// author wrote first (#609), so the rendered BibTeX is clean enough to
174/// paste into a `.bib`. It is a markup scrubber, not a TeX-aware math
175/// translator: a title's math markup collapses to its plain-text content
176/// rather than to `$…$`.
177fn 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// ---------------------------------------------------------------------------
193// CSL JSON 1.0
194// ---------------------------------------------------------------------------
195
196/// Render `m` as a CSL JSON 1.0 **array** (a single-element array, so it
197/// is a drop-in for citeproc-js / pandoc `--csl-json` consumers that
198/// expect a list of items), keyed by `citation_key`.
199///
200/// `journal-article` → CSL `article-journal`; everything else →
201/// `manuscript` (citeproc-js renders that without forcing a container).
202/// Empty optional fields are omitted from the JSON.
203#[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/// [`to_csl_array`], adding `container-title-short` from
209/// `[doiget].short_venue` when `short_container` is set and the record
210/// carried an abbreviation (#611).
211#[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    // `CslItem` is all-`Serialize` over owned/borrowed primitives, so
222    // `to_value` cannot fail; fall back to an empty array rather than
223    // panicking if a future field breaks that invariant.
224    serde_json::to_value([item]).unwrap_or_else(|_| serde_json::Value::Array(Vec::new()))
225}
226
227/// One CSL JSON 1.0 item, scoped to the binding fields the local
228/// `Metadata` schema can populate. Field order is the citeproc-js
229/// conventional order so a human diffing two outputs sees a stable
230/// column layout.
231#[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    /// Software only (#614).
261    #[serde(skip_serializing_if = "Option::is_none")]
262    version: Option<&'a str>,
263    /// Software only (#614): the release is found by its URL.
264    #[serde(rename = "URL", skip_serializing_if = "Option::is_none")]
265    url: Option<&'a str>,
266}
267
268/// CSL name-variable shape. Empty halves are omitted so a single-token
269/// name lands as `{"family": "Plato"}` rather than with a stray `given`.
270#[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/// CSL date-variable shape, year-only for Phase 1. `date-parts` is a
279/// list-of-lists; we only know the year so the inner list is `[<year>]`.
280#[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            // CSL 1.0.2 `software` (#614).
293            _ 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
318/// Split a free-form name string into CSL `family` / `given` halves.
319///
320/// - `Family, Given` (comma present): split on the first comma.
321/// - Otherwise split on the LAST whitespace: left is given, right is
322///   family (`"Alice Researcher"` → family `"Researcher"`, given
323///   `"Alice"`) — the convention citeproc-js uses for string names.
324/// - Single token: whole string is the family, `given` empty.
325fn 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// ---------------------------------------------------------------------------
347// Tests
348// ---------------------------------------------------------------------------
349
350#[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    // ---- BibTeX ----
404
405    #[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        // issue #303: an arXiv entry must carry the preprint identity, not
434        // just title + author. New-style id → `primaryClass` from the
435        // parsed Atom category (`arxiv_categories[0]`).
436        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        // Long keys are not padded, so the field lines are exact.
449        assert!(s.contains("archivePrefix = {arXiv},"), "{s}");
450        assert!(s.contains("primaryClass = {cond-mat.str-el},"), "{s}");
451        // `eprint` is a short key; assert the value to avoid padding fuss.
452        assert!(s.contains("eprint") && s.contains("= {2012.03644},"), "{s}");
453        // Year still rendered (populated by the cite overlay upstream).
454        assert!(s.contains("year       = {2026},"), "{s}");
455    }
456
457    #[test]
458    fn bibtex_arxiv_old_style_id_primaryclass_from_prefix() {
459        // No stored categories (e.g. a pre-#303 store entry): the old-style
460        // id's archive prefix supplies `primaryClass`.
461        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        // A DOI-only entry must not grow arXiv fields.
473        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        // A Crossref-style title with MathML + an inline italic tag.
515        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        // #609: CSL is the other render; it borrowed the raw string before.
528        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        // Review of #618: the old scrubber (and the first cut of the new
555        // one) read `T<Tc ... H>` as a tag and dropped the text between.
556        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        // A bare `<` with no closing `>` is genuine math, not a tag:
564        // keep the remainder verbatim rather than swallowing it.
565        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    // ---- CSL ----
571
572    #[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}