Skip to main content

doiget_cli/commands/
bib.rs

1//! `doiget bib` subcommand — emit BibTeX from the **local store**, offline.
2//!
3//! Three shapes (issue #305):
4//! - `bib <ref>` — one stored entry (the original behavior).
5//! - `bib --all` — every entry in the store as one deduplicated `.bib`.
6//! - `bib --from-file <FILE>` — the refs listed in FILE, each rendered from
7//!   the store; entries not present are skipped and counted toward a
8//!   non-zero exit.
9//!
10//! All shapes are pure store reads — no network — so "fetch a batch, then
11//! emit a complete `.bib`" is a single offline command. The actual
12//! rendering lives in [`doiget_core::store::render::to_bibtex`] so the CLI
13//! and the `doiget_bibtex_export` MCP tool share one implementation.
14
15use std::io::Write;
16
17use anyhow::{bail, Context, Result};
18use camino::{Utf8Path, Utf8PathBuf};
19
20use doiget_core::refs::{self, Format};
21use doiget_core::store::{render, FsStore, Metadata, Store};
22use doiget_core::Safekey;
23
24use super::fetch::CliExit;
25use super::output::print_err;
26use super::resolve_store_root;
27
28/// Run the `bib` subcommand against the configured store.
29///
30/// Exactly one selector must be supplied: a positional `ref_`, `--all`, or
31/// `--from-file`. The BibTeX output is product output (the requested
32/// artifact), not informational, so Quiet does NOT suppress it. A missing
33/// single entry — or any missing ref under `--from-file` — yields a
34/// non-zero exit (never a silent empty stdout: the #302 / #304 contract).
35pub fn run(
36    ref_: Option<String>,
37    all: bool,
38    from_file: Option<Utf8PathBuf>,
39    _mode: super::output::OutputMode,
40) -> Result<()> {
41    // Exactly-one-of selector validation (clap leaves all three optional).
42    let selectors =
43        usize::from(ref_.is_some()) + usize::from(all) + usize::from(from_file.is_some());
44    if selectors == 0 {
45        bail!("specify a ref, `--all`, or `--from-file <FILE>`");
46    }
47    if selectors > 1 {
48        bail!("`--all`, `--from-file`, and a positional ref are mutually exclusive");
49    }
50
51    let store = FsStore::new(resolve_store_root()?)?;
52
53    if all {
54        return run_all(&store);
55    }
56    if let Some(path) = from_file {
57        return run_from_file(&store, &path);
58    }
59
60    // Single ref (the original behavior). Selector validation above
61    // guarantees `ref_` is Some here; match rather than `expect` (the
62    // workspace denies `expect`/`panic` in non-test code).
63    let input = match ref_ {
64        Some(r) => r,
65        None => bail!("specify a ref, `--all`, or `--from-file <FILE>`"),
66    };
67    let ref_ = super::parse_ref_or_exit(&input)?;
68    let safekey = ref_.safekey();
69    match store.read(&safekey)? {
70        Some(m) => write_all(&render::to_bibtex(safekey.as_str(), &m)),
71        None => bail!("no entry for {input}"),
72    }
73}
74
75/// `--all`: render every store entry, deduplicated by citation key, as one
76/// `.bib`. An empty store is not a failure — it exits 0 with a stderr note,
77/// since "nothing to export" is a valid outcome (no ref was requested).
78fn run_all(store: &FsStore) -> Result<()> {
79    // `usize::MAX` ⇒ every entry; `list_recent` already orders by recency.
80    let entries = store
81        .list_recent(usize::MAX)
82        .context("failed to enumerate the store")?;
83
84    if entries.is_empty() {
85        print_err(format_args!(
86            "bib --all: the store is empty; nothing to export"
87        ));
88        return Ok(());
89    }
90
91    let mut out = String::new();
92    let mut seen: Vec<String> = Vec::new();
93    let mut rendered = 0usize;
94    for e in &entries {
95        // An entry listed but unreadable (deleted/raced) is skipped loudly
96        // rather than aborting the whole export.
97        match store.read(&e.safekey) {
98            Ok(Some(m)) => push_entry(&mut out, &mut seen, &e.safekey, &m, &mut rendered),
99            Ok(None) => {}
100            Err(err) => print_err(format_args!(
101                "bib --all: skipping {} (read failed: {err})",
102                e.safekey.as_str()
103            )),
104        }
105    }
106
107    write_all(&out)?;
108    print_err(format_args!("bib --all: exported {rendered} entries"));
109    Ok(())
110}
111
112/// `--from-file`: render the refs listed in `path` from the store. Missing
113/// entries are skipped (with a stderr note) and counted; the process exits
114/// non-zero (failure count, capped at 255 — same convention as `batch`)
115/// when any requested ref could not be rendered, so a script can tell a
116/// complete export from a partial one.
117fn run_from_file(store: &FsStore, path: &Utf8Path) -> Result<()> {
118    let raw = std::fs::read_to_string(path)
119        .with_context(|| format!("reading --from-file list: {path}"))?;
120    // Same bibliography adapter `batch` uses: plain refs / CSL-JSON /
121    // BibTeX, auto-detected by extension + content.
122    let parsed = refs::parse_input(&raw, Format::Auto, Some(path));
123
124    let mut out = String::new();
125    let mut seen: Vec<String> = Vec::new();
126    let mut rendered = 0usize;
127    let mut missing = 0usize;
128    for entry in parsed {
129        let ref_ = match entry {
130            Ok(p) => p.ref_,
131            Err(e) => {
132                missing += 1;
133                print_err(format_args!(
134                    "bib --from-file: skipping unparsable entry ({e})"
135                ));
136                continue;
137            }
138        };
139        let safekey = ref_.safekey();
140        // A read error on one entry skips that entry (counted), matching
141        // `run_all` — it must NOT abort the whole export and lose every
142        // remaining ref (review #318).
143        match store.read(&safekey) {
144            Ok(Some(m)) => push_entry(&mut out, &mut seen, &safekey, &m, &mut rendered),
145            Ok(None) => {
146                missing += 1;
147                print_err(format_args!(
148                    "bib --from-file: no store entry for {} (skipped)",
149                    ref_.as_input_str()
150                ));
151            }
152            Err(err) => {
153                missing += 1;
154                print_err(format_args!(
155                    "bib --from-file: read failed for {} ({err}; skipped)",
156                    ref_.as_input_str()
157                ));
158            }
159        }
160    }
161
162    write_all(&out)?;
163    print_err(format_args!(
164        "bib --from-file: exported {rendered} entries, {missing} missing"
165    ));
166    if missing > 0 {
167        // `docs/ERRORS.md` §4: exit = number of failures, capped at 255.
168        let code = missing.min(255) as i32;
169        return Err(anyhow::Error::new(CliExit(code)));
170    }
171    Ok(())
172}
173
174/// Append one rendered entry to `out`, deduplicated by citation key
175/// (`safekey`): a ref list may name the same paper twice, and a deduped
176/// `.bib` avoids duplicate-key warnings in LaTeX.
177fn push_entry(
178    out: &mut String,
179    seen: &mut Vec<String>,
180    safekey: &Safekey,
181    m: &Metadata,
182    rendered: &mut usize,
183) {
184    let key = safekey.as_str();
185    if seen.iter().any(|k| k == key) {
186        return;
187    }
188    seen.push(key.to_string());
189    if !out.is_empty() {
190        // Blank line between entries for readability; entries already end
191        // with `}\n`.
192        out.push('\n');
193    }
194    out.push_str(&render::to_bibtex(key, m));
195    *rendered += 1;
196}
197
198/// Write the rendered BibTeX to stdout. Workspace lints deny
199/// `print!`/`println!`; `write!` against an explicit `stdout().lock()` is
200/// the sanctioned escape hatch (ADR-0001). Entries already terminate with
201/// `}\n`, so no trailing newline is added.
202fn write_all(bib: &str) -> Result<()> {
203    let stdout = std::io::stdout();
204    let mut out = stdout.lock();
205    write!(out, "{bib}").context("failed to write BibTeX to stdout")
206}