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}