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::print_err;
22use super::output::OutputMode;
23
24/// Run the `tex-source` subcommand.
25///
26/// # Errors
27///
28/// Returns a typed [`ErrorCode`] as a process exit code via [`CliExit`].
29pub async fn run(
30    ref_: String,
31    max_chars: Option<usize>,
32    no_cache: bool,
33    mode: OutputMode,
34    quiet_was_explicit: bool,
35) -> Result<()> {
36    // #492 / ADR-0049: one renderer, one exit code. This used to be
37    // `Ref::parse(..).with_context(..)?`, which exited 1 and leaked the
38    // `Caused by:` chain that #477's contract exists to replace — the ADR
39    // claimed the rule held for "every ref-taking command" and this was one
40    // of two that were never in the set.
41    let parsed = super::parse_ref_or_exit(&ref_)?;
42    let id: ArxivId = match parsed {
43        Ref::Arxiv(a) => a,
44        Ref::Doi(_) => {
45            let code = ErrorCode::NoOaAvailable;
46            print_err(format_args!(
47                "error[{}]: no TeX source for a bare DOI — if an arXiv preprint exists, \
48                 pass its id (e.g. `doiget tex-source arxiv:2401.12345`)",
49                code.as_wire()
50            ));
51            return Err(anyhow::Error::new(CliExit(cli_exit_code(code))));
52        }
53    };
54
55    let base = resolve_arxiv_src_base().map_err(|e| anyhow::anyhow!("{e}"))?;
56    let mut ctx = build_resolve_context().context("building fetch context")?;
57    if no_cache {
58        ctx.cache_root = None;
59    }
60
61    let tex = match paper_tex_source(&base, &id, max_chars, &ctx).await {
62        Ok(t) => t,
63        Err(e) => {
64            let code = ErrorCode::from(&e);
65            print_err(format_args!("error[{}]: {e}", code.as_wire()));
66            if code == ErrorCode::TextUnavailable {
67                print_err(format_args!(
68                    "  = note: no TeX source available (PDF-only or no .tex files). \
69                     Fetch the PDF instead: `doiget fetch arxiv:{}`",
70                    id.as_str()
71                ));
72            }
73            return Err(anyhow::Error::new(CliExit(cli_exit_code(code))));
74        }
75    };
76
77    // TeX source is the requested artifact — suppress only on *explicit* Quiet
78    // (ADR-0017 Amendment 2, same logic as `doiget text`).
79    if mode == OutputMode::Quiet && quiet_was_explicit {
80        return Ok(());
81    }
82
83    let stdout = std::io::stdout();
84    let mut out = stdout.lock();
85    if mode == OutputMode::Json {
86        let s = serde_json::to_string_pretty(&tex).context("serializing tex-source JSON")?;
87        writeln!(out, "{s}").context("writing tex-source JSON to stdout")?;
88        return Ok(());
89    }
90
91    render_human(&mut out, &tex)?;
92    Ok(())
93}
94
95fn render_human(out: &mut impl Write, tex: &PaperTexSource) -> Result<()> {
96    if let Some(f) = &tex.main_file {
97        writeln!(out, "% source: {f}").context("writing file header")?;
98    }
99    writeln!(out, "{}", tex.tex_source).context("writing tex source to stdout")?;
100    if tex.truncated {
101        print_err(format_args!(
102            "note: output truncated to {} chars (raise or drop --max-chars for the full source)",
103            tex.char_count
104        ));
105    }
106    Ok(())
107}
108
109#[cfg(test)]
110#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic, missing_docs)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn human_render_emits_file_header_and_source() {
116        let tex = PaperTexSource {
117            arxiv_id: "2401.12345".into(),
118            main_file: Some("main.tex".into()),
119            tex_source: "\\documentclass{article}".into(),
120            char_count: 23,
121            truncated: false,
122            retrieved_from: "https://export.arxiv.org/src/2401.12345".into(),
123        };
124        let mut buf: Vec<u8> = Vec::new();
125        render_human(&mut buf, &tex).expect("render");
126        let s = String::from_utf8(buf).expect("utf8");
127        assert!(s.contains("% source: main.tex"), "got: {s}");
128        assert!(s.contains("\\documentclass"), "got: {s}");
129    }
130
131    #[test]
132    fn json_envelope_has_expected_fields() {
133        let tex = PaperTexSource {
134            arxiv_id: "2401.12345".into(),
135            main_file: None,
136            tex_source: "\\documentclass{article}".into(),
137            char_count: 23,
138            truncated: false,
139            retrieved_from: "https://export.arxiv.org/src/2401.12345".into(),
140        };
141        let v = serde_json::to_value(&tex).expect("serialize");
142        assert_eq!(v["arxiv_id"], "2401.12345");
143        assert_eq!(v["truncated"], false);
144        assert_eq!(v["char_count"], 23);
145    }
146}