Skip to main content

doiget_cli/commands/
tex_source.rs

1//! `doiget tex-source <ref>` — fetch the raw LaTeX source of an arXiv paper.
2//!
3//! Fetches the arXiv source tarball (`export.arxiv.org/src/<id>`), extracts
4//! the main `.tex` file, and emits its content. This is the structured-text
5//! complement to `doiget text` (ar5iv HTML extraction) and is more reliable
6//! for papers that ar5iv has not processed.
7//!
8//! - **arXiv id** → raw LaTeX source via
9//!   [`doiget_core::paper_tex_source::paper_tex_source`].
10//! - **DOI** → structured `NO_OA_AVAILABLE`.
11//! - **PDF-only submission** → `TEXT_UNAVAILABLE` with an actionable note.
12
13use std::io::Write;
14
15use anyhow::{Context, Result};
16
17use doiget_core::paper_tex_source::{paper_tex_source, resolve_arxiv_src_base, PaperTexSource};
18use doiget_core::{ArxivId, ErrorCode, Ref};
19
20use super::fetch::{build_resolve_context, cli_exit_code, CliExit};
21use super::output::OutputMode;
22
23#[allow(clippy::print_stderr)]
24fn print_err(args: std::fmt::Arguments<'_>) {
25    eprintln!("{args}");
26}
27
28/// Run the `tex-source` subcommand.
29///
30/// # Errors
31///
32/// Returns a typed [`ErrorCode`] as a process exit code via [`CliExit`].
33pub async fn run(
34    ref_: String,
35    max_chars: Option<usize>,
36    no_cache: bool,
37    mode: OutputMode,
38    quiet_was_explicit: bool,
39) -> Result<()> {
40    let parsed = Ref::parse(&ref_).with_context(|| format!("invalid ref {ref_:?}"))?;
41    let id: ArxivId = match parsed {
42        Ref::Arxiv(a) => a,
43        Ref::Doi(_) => {
44            let code = ErrorCode::NoOaAvailable;
45            print_err(format_args!(
46                "error[{}]: no TeX source for a bare DOI — if an arXiv preprint exists, \
47                 pass its id (e.g. `doiget tex-source arxiv:2401.12345`)",
48                code.as_wire()
49            ));
50            return Err(anyhow::Error::new(CliExit(cli_exit_code(code))));
51        }
52    };
53
54    let base = resolve_arxiv_src_base().map_err(|e| anyhow::anyhow!("{e}"))?;
55    let mut ctx = build_resolve_context().context("building fetch context")?;
56    if no_cache {
57        ctx.cache_root = None;
58    }
59
60    let tex = match paper_tex_source(&base, &id, max_chars, &ctx).await {
61        Ok(t) => t,
62        Err(e) => {
63            let code = ErrorCode::from(&e);
64            print_err(format_args!("error[{}]: {e}", code.as_wire()));
65            if code == ErrorCode::TextUnavailable {
66                print_err(format_args!(
67                    "  = note: no TeX source available (PDF-only or no .tex files). \
68                     Fetch the PDF instead: `doiget fetch arxiv:{}`",
69                    id.as_str()
70                ));
71            }
72            return Err(anyhow::Error::new(CliExit(cli_exit_code(code))));
73        }
74    };
75
76    // TeX source is the requested artifact — suppress only on *explicit* Quiet
77    // (ADR-0017 Amendment 2, same logic as `doiget text`).
78    if mode == OutputMode::Quiet && quiet_was_explicit {
79        return Ok(());
80    }
81
82    let stdout = std::io::stdout();
83    let mut out = stdout.lock();
84    if mode == OutputMode::Json {
85        let s = serde_json::to_string_pretty(&tex).context("serializing tex-source JSON")?;
86        writeln!(out, "{s}").context("writing tex-source JSON to stdout")?;
87        return Ok(());
88    }
89
90    render_human(&mut out, &tex)?;
91    Ok(())
92}
93
94fn render_human(out: &mut impl Write, tex: &PaperTexSource) -> Result<()> {
95    if let Some(f) = &tex.main_file {
96        writeln!(out, "% source: {f}").context("writing file header")?;
97    }
98    writeln!(out, "{}", tex.tex_source).context("writing tex source to stdout")?;
99    if tex.truncated {
100        print_err(format_args!(
101            "note: output truncated to {} chars (raise or drop --max-chars for the full source)",
102            tex.char_count
103        ));
104    }
105    Ok(())
106}
107
108#[cfg(test)]
109#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic, missing_docs)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn human_render_emits_file_header_and_source() {
115        let tex = PaperTexSource {
116            arxiv_id: "2401.12345".into(),
117            main_file: Some("main.tex".into()),
118            tex_source: "\\documentclass{article}".into(),
119            char_count: 23,
120            truncated: false,
121            retrieved_from: "https://export.arxiv.org/src/2401.12345".into(),
122        };
123        let mut buf: Vec<u8> = Vec::new();
124        render_human(&mut buf, &tex).expect("render");
125        let s = String::from_utf8(buf).expect("utf8");
126        assert!(s.contains("% source: main.tex"), "got: {s}");
127        assert!(s.contains("\\documentclass"), "got: {s}");
128    }
129
130    #[test]
131    fn json_envelope_has_expected_fields() {
132        let tex = PaperTexSource {
133            arxiv_id: "2401.12345".into(),
134            main_file: None,
135            tex_source: "\\documentclass{article}".into(),
136            char_count: 23,
137            truncated: false,
138            retrieved_from: "https://export.arxiv.org/src/2401.12345".into(),
139        };
140        let v = serde_json::to_value(&tex).expect("serialize");
141        assert_eq!(v["arxiv_id"], "2401.12345");
142        assert_eq!(v["truncated"], false);
143        assert_eq!(v["char_count"], 23);
144    }
145}