Skip to main content

doiget_cli/commands/
cite.rs

1//! `doiget cite <ref>` subcommand — resolve a DOI / arXiv reference to a
2//! clean BibTeX entry on stdout, a `doi2bib`-style citation helper.
3//!
4//! Unlike [`bib`](super::bib) (which renders an entry already in the local
5//! store), `cite` resolves the reference **live** — cache-aware via the
6//! resolver cache (`docs/CACHE.md`), so repeat citations of the same ref
7//! avoid upstream rate limits — and never writes to the store. The DOI
8//! path enriches the entry from the Crossref envelope
9//! ([`doiget_core::orchestrator::cite_metadata`]) so the output carries
10//! year / journal / publisher / ISSN, not just the bare id.
11//!
12//! ## Offline resilience (issue #305)
13//!
14//! A live resolve that fails (network hiccup, OpenAlex flake) does NOT
15//! discard an already-fetched reference: `cite` falls back to the local
16//! store and renders the stored metadata, with a `note:` on stderr so the
17//! offline path is visible. `--offline` skips the live resolve entirely
18//! (store-only). Either way a total miss is a non-zero error, never a
19//! silent empty stdout (the #302 / #304 "never exit 0 with nothing"
20//! contract).
21//!
22//! Rendering (field mapping, brace-stripping, HTML/MathML tag scrubbing)
23//! is shared with `doiget bib` via
24//! [`doiget_core::store::render::to_bibtex`].
25//!
26//! ## Relation to doi2bib
27//!
28//! This command is functionally comparable to the `doi2bib` tool, and the
29//! "doi2bib-style" / "doi2bib-quality" phrasing throughout doiget is a
30//! descriptive comparison only. `cite` is an **independent, clean-room
31//! implementation** built on doiget's own Crossref/arXiv resolver and
32//! `to_bibtex` renderer; it incorporates **no code** from any external
33//! doi2bib project. In particular it does not derive from the AGPL-3.0
34//! `doi2bib` at <https://github.com/vandroogenbroeckmarc/doi2bib> — none
35//! of that project's source, field-correction heuristics, or
36//! Unicode→ASCII tables are used here, so doiget remains MIT-licensed.
37
38use std::io::Write;
39
40use anyhow::{anyhow, Context, Result};
41
42use doiget_core::orchestrator::{cite_metadata, resolve_only, MetadataOnlyOutcome};
43use doiget_core::store::{render, FsStore, Metadata, Store};
44use doiget_core::{CapabilityProfile, Ref};
45
46use super::output::print_err;
47use super::resolve_store_root;
48
49/// Run the `cite` subcommand.
50///
51/// `input` is the user-supplied ref string (a DOI, `arxiv:<id>`, or any
52/// scheme accepted by [`Ref::parse`]). When `offline` is set the live
53/// resolve is skipped and the entry is rendered from the local store;
54/// otherwise a live resolve is attempted first and the store is used as a
55/// fallback when it fails.
56///
57/// On success a BibTeX entry is written to stdout. Like `bib`, the BibTeX
58/// is the requested artifact (product output, not a diagnostic), so
59/// `--quiet` does NOT suppress it. A total miss (no live resolve and no
60/// store entry) returns an error so the CLI exits non-zero.
61pub async fn run(input: String, offline: bool, _mode: super::output::OutputMode) -> Result<()> {
62    let ref_ = super::parse_ref_or_exit(&input)?;
63
64    // `--offline`: render straight from the store, no network at all.
65    if offline {
66        let bib = bib_from_store(&ref_)?.ok_or_else(|| {
67            anyhow!(
68                "--offline: no local store entry for {input} (fetch it first with `doiget fetch`)"
69            )
70        })?;
71        return write_bib(&bib);
72    }
73
74    let ctx = crate::commands::fetch::build_resolve_context()?;
75    let profile = CapabilityProfile::from_env().context("resolving capability profile")?;
76
77    match resolve_only(&ref_, &profile, &ctx).await {
78        Ok(outcome) => {
79            let mut metadata = cite_metadata(&ref_, &outcome);
80            // #303 published-version merge: when an arXiv preprint's Atom
81            // feed cross-references a published journal DOI (`<arxiv:doi>`),
82            // resolve that DOI (Crossref) and prefer its rich `@article`
83            // fields (journal / volume / issue / pages / publisher / issn /
84            // doi) while RETAINING the arXiv preprint identity
85            // (eprint / archivePrefix / primaryClass). Best-effort: a
86            // missing or unresolvable cross-ref keeps the `@misc` preprint
87            // entry, never failing the cite. No extra OpenAlex call — the
88            // DOI comes free from the Atom feed already fetched.
89            if let Some(doi_ref) = published_doi_ref(&ref_, &outcome) {
90                match resolve_only(&doi_ref, &profile, &ctx).await {
91                    Ok(doi_outcome) => {
92                        metadata = merge_published(cite_metadata(&doi_ref, &doi_outcome), metadata);
93                    }
94                    // Best-effort, but make the degradation VISIBLE (review
95                    // #318): a published version exists yet could not be
96                    // resolved, so we fall back to the @misc preprint — say
97                    // so on stderr rather than silently.
98                    Err(e) => print_err(format_args!(
99                        "note: published-version DOI resolve failed ({e}); citing the arXiv preprint"
100                    )),
101                }
102            }
103            let bib = render::to_bibtex(ref_.safekey().as_str(), &metadata);
104            write_bib(&bib)
105        }
106        Err(e) => {
107            // Live resolve failed. Fall back to the store so an
108            // already-fetched ref still cites (issue #305) — but never a
109            // silent empty stdout: a ref that is in neither place is a
110            // non-zero error carrying the original resolve failure.
111            match bib_from_store(&ref_)? {
112                Some(bib) => {
113                    print_err(format_args!(
114                        "note: live resolve failed ({e}); citing offline from the local store"
115                    ));
116                    write_bib(&bib)
117                }
118                None => Err(anyhow::Error::new(e).context(format!(
119                    "failed to resolve {input}, and no local store entry to cite offline"
120                ))),
121            }
122        }
123    }
124}
125
126/// The published-journal DOI an arXiv Atom feed cross-references via
127/// `<arxiv:doi>` (issue #303), as a `Ref::Doi`. `None` for a DOI input, an
128/// absent cross-ref, or a malformed DOI (a bad cross-ref is simply ignored
129/// rather than failing the cite).
130fn published_doi_ref(ref_: &Ref, outcome: &MetadataOnlyOutcome) -> Option<Ref> {
131    if !matches!(ref_, Ref::Arxiv(_)) {
132        return None;
133    }
134    let doi = outcome.metadata.get("doi").and_then(|v| v.as_str())?;
135    // Narrow to a DOI: a URL-form or arXiv-shaped cross-ref must NOT trigger
136    // a spurious second arXiv resolve against the wrong id (review #318). A
137    // value that does not parse as a bare DOI is simply ignored.
138    match Ref::parse(doi).ok()? {
139        r @ Ref::Doi(_) => Some(r),
140        Ref::Arxiv(_) => None,
141    }
142}
143
144/// Merge the arXiv preprint identity into the published DOI's `@article`
145/// metadata: keep the rich Crossref entry and graft on the arXiv id +
146/// categories so `to_bibtex` still emits `eprint` / `archivePrefix` /
147/// `primaryClass`. The published record wins on every shared field — it is
148/// the version a reader should cite — with the preprint retained for
149/// discoverability.
150fn merge_published(mut article: Metadata, arxiv: Metadata) -> Metadata {
151    article.arxiv_id = arxiv.arxiv_id;
152    article.arxiv_categories = arxiv.arxiv_categories;
153    article
154}
155
156/// Render the stored BibTeX for `ref_`, or `None` when the store has no
157/// entry. The citation key is the entry's safekey, matching `bib`.
158fn bib_from_store(ref_: &Ref) -> Result<Option<String>> {
159    let store = FsStore::new(resolve_store_root()?)?;
160    let safekey = ref_.safekey();
161    Ok(store
162        .read(&safekey)?
163        .map(|m| render::to_bibtex(safekey.as_str(), &m)))
164}
165
166/// Write a rendered BibTeX entry to stdout. `to_bibtex` already terminates
167/// the entry with `}\n`, so no extra newline is added. Workspace lints deny
168/// `print!`/`println!`; `write!` against an explicit `stdout().lock()` is
169/// the sanctioned escape hatch (ADR-0001).
170fn write_bib(bib: &str) -> Result<()> {
171    let stdout = std::io::stdout();
172    let mut out = stdout.lock();
173    write!(out, "{bib}").context("failed to write BibTeX entry to stdout")
174}