Skip to main content

doiget_cli/commands/
csl.rs

1//! `doiget csl` — emit CSL JSON 1.0 from the **local store**, offline.
2//!
3//! Three shapes (parity with `bib`, issue #305):
4//! - `csl <ref>` — a single-element CSL JSON array for one stored entry.
5//! - `csl --all` — every store entry as one deduplicated CSL JSON array.
6//! - `csl --from-file <FILE>` — the refs listed in FILE (plain refs /
7//!   CSL-JSON / BibTeX), each rendered from the store; missing entries are
8//!   skipped and counted toward a non-zero exit.
9//!
10//! All shapes are pure store reads — no network. Rendering lives in
11//! [`doiget_core::store::render::to_csl_array`] so the CLI and the
12//! `doiget_csl_export` MCP tool share one implementation.
13
14use std::io::Write;
15
16use anyhow::{bail, Context, Result};
17use camino::{Utf8Path, Utf8PathBuf};
18use serde_json::Value;
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 `csl` subcommand against the configured store.
29///
30/// Exactly one selector must be supplied: a positional `ref_`, `--all`, or
31/// `--from-file`. CSL JSON is product output, so Quiet does NOT suppress
32/// it. A missing single entry — or any missing ref under `--from-file` —
33/// yields a non-zero exit. Under `--from-file` the partial array is still
34/// written to stdout before the failure (a possibly-empty array); a missing
35/// single positional ref produces no stdout at all.
36pub fn run(
37    ref_: Option<String>,
38    all: bool,
39    from_file: Option<Utf8PathBuf>,
40    _mode: super::output::OutputMode,
41) -> Result<()> {
42    let selectors =
43        usize::from(ref_.is_some()) + usize::from(all) + usize::from(from_file.is_some());
44    if selectors > 1 {
45        bail!("`--all`, `--from-file`, and a positional ref are mutually exclusive");
46    }
47    // Fail the usage error BEFORE opening the store (parity with `bib`,
48    // review #318): otherwise a store-open failure would mask the real
49    // "no selector" mistake.
50    if selectors == 0 {
51        bail!("specify a ref, `--all`, or `--from-file <FILE>`");
52    }
53
54    let store = FsStore::new(resolve_store_root()?)?;
55
56    if all {
57        return run_all(&store);
58    }
59    if let Some(path) = from_file {
60        return run_from_file(&store, &path);
61    }
62
63    // Single ref (the original behavior): a one-element array.
64    let input = match ref_ {
65        Some(r) => r,
66        None => bail!("specify a ref, `--all`, or `--from-file <FILE>`"),
67    };
68    let ref_ = super::parse_ref_or_exit(&input)?;
69    let safekey = ref_.safekey();
70    match store.read(&safekey)? {
71        Some(m) => write_array(&render::to_csl_array(safekey.as_str(), &m)),
72        None => bail!("no entry for {input}"),
73    }
74}
75
76/// `--all`: every store entry as one deduplicated CSL JSON array. An empty
77/// store emits `[]` with a stderr note (exit 0 — no ref was requested).
78fn run_all(store: &FsStore) -> Result<()> {
79    let entries = store
80        .list_recent(usize::MAX)
81        .context("failed to enumerate the store")?;
82
83    let mut items: Vec<Value> = Vec::new();
84    let mut seen: Vec<String> = Vec::new();
85    for e in &entries {
86        match store.read(&e.safekey) {
87            Ok(Some(m)) => push_item(&mut items, &mut seen, &e.safekey, &m),
88            Ok(None) => {}
89            Err(err) => print_err(format_args!(
90                "csl --all: skipping {} (read failed: {err})",
91                e.safekey.as_str()
92            )),
93        }
94    }
95
96    write_array(&Value::Array(items))?;
97    print_err(format_args!("csl --all: exported {} entries", seen.len()));
98    Ok(())
99}
100
101/// `--from-file`: render the refs listed in `path` from the store. Missing
102/// entries are skipped (stderr note) and counted; the process exits
103/// non-zero (failure count, capped at 255 — same convention as `batch` /
104/// `bib`) when any requested ref could not be rendered.
105fn run_from_file(store: &FsStore, path: &Utf8Path) -> Result<()> {
106    let raw = std::fs::read_to_string(path)
107        .with_context(|| format!("reading --from-file list: {path}"))?;
108    // Same bibliography adapter `batch` / `bib` use: plain refs / CSL-JSON
109    // / BibTeX, auto-detected by extension + content.
110    let parsed = refs::parse_input(&raw, Format::Auto, Some(path));
111
112    let mut items: Vec<Value> = Vec::new();
113    let mut seen: Vec<String> = Vec::new();
114    let mut missing = 0usize;
115    for entry in parsed {
116        let ref_ = match entry {
117            Ok(p) => p.ref_,
118            Err(e) => {
119                missing += 1;
120                print_err(format_args!(
121                    "csl --from-file: skipping unparsable entry ({e})"
122                ));
123                continue;
124            }
125        };
126        let safekey = ref_.safekey();
127        // A read error on one entry skips that entry (counted), matching
128        // `run_all` — it must NOT abort the whole export and lose every
129        // remaining ref (review #318).
130        match store.read(&safekey) {
131            Ok(Some(m)) => push_item(&mut items, &mut seen, &safekey, &m),
132            Ok(None) => {
133                missing += 1;
134                print_err(format_args!(
135                    "csl --from-file: no store entry for {} (skipped)",
136                    ref_.as_input_str()
137                ));
138            }
139            Err(err) => {
140                missing += 1;
141                print_err(format_args!(
142                    "csl --from-file: read failed for {} ({err}; skipped)",
143                    ref_.as_input_str()
144                ));
145            }
146        }
147    }
148
149    write_array(&Value::Array(items))?;
150    print_err(format_args!(
151        "csl --from-file: exported {} entries, {missing} missing",
152        seen.len()
153    ));
154    if missing > 0 {
155        let code = missing.min(255) as i32;
156        return Err(anyhow::Error::new(CliExit(code)));
157    }
158    Ok(())
159}
160
161/// Append one entry's CSL item(s) to `items`, deduplicated by citation key
162/// (`safekey`). `to_csl_array` returns a single-element array; its elements
163/// are flattened into the combined array so the output is one flat CSL
164/// list (what citeproc-js / pandoc expect).
165fn push_item(items: &mut Vec<Value>, seen: &mut Vec<String>, safekey: &Safekey, m: &Metadata) {
166    let key = safekey.as_str();
167    if seen.iter().any(|k| k == key) {
168        return;
169    }
170    seen.push(key.to_string());
171    match render::to_csl_array(key, m) {
172        Value::Array(rendered) if !rendered.is_empty() => items.extend(rendered),
173        // `to_csl_array` falls back to an empty array on a (rare)
174        // serialization failure. Don't silently drop the entry from the
175        // export — surface it (review #318), consistent with the release's
176        // never-silently-lose-data contract.
177        other => tracing::error!(
178            safekey = key,
179            value = %other,
180            "to_csl_array produced no CSL item; entry omitted from export"
181        ),
182    }
183}
184
185/// Serialize and write a CSL JSON array to stdout. Workspace lints deny
186/// `print!`/`println!`; `writeln!` against an explicit `stdout().lock()` is
187/// the sanctioned escape hatch (ADR-0001).
188fn write_array(array: &Value) -> Result<()> {
189    let json =
190        serde_json::to_string_pretty(array).context("failed to serialize CSL JSON for stdout")?;
191    let stdout = std::io::stdout();
192    let mut out = stdout.lock();
193    writeln!(out, "{json}").context("failed to write CSL JSON to stdout")
194}