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            // `NOT_IMPLEMENTED`, not `NO_OA_AVAILABLE`. The latter carries
46            // disposition `needs_config` -- "a named change makes it" -- and
47            // there is no knob: this command is arXiv-only and DOI-to-arXiv
48            // linking is not built (#281 item 5). Kept identical to the MCP
49            // sibling; changing one surface and not the other is the defect
50            // this release is about, and the first pass at this fix did
51            // exactly that.
52            let code = ErrorCode::NotImplemented;
53            print_err(format_args!(
54                "error[{}]: no TeX source for a bare DOI — if an arXiv preprint exists, \
55                 pass its id (e.g. `doiget tex-source arxiv:2401.12345`)",
56                code.as_wire()
57            ));
58            return Err(anyhow::Error::new(CliExit(cli_exit_code(code))));
59        }
60    };
61
62    let base = resolve_arxiv_src_base().map_err(|e| anyhow::anyhow!("{e}"))?;
63    let mut ctx = build_resolve_context().context("building fetch context")?;
64    if no_cache {
65        ctx.cache_root = None;
66    }
67
68    let tex = match paper_tex_source(&base, &id, max_chars, &ctx).await {
69        Ok(t) => t,
70        Err(e) => {
71            let code = ErrorCode::from(&e);
72            print_err(format_args!("error[{}]: {e}", code.as_wire()));
73            if code == ErrorCode::TextUnavailable {
74                print_err(format_args!(
75                    "  = note: no TeX source available (PDF-only or no .tex files). \
76                     Fetch the PDF instead: `doiget fetch arxiv:{}`",
77                    id.as_str()
78                ));
79            }
80            return Err(anyhow::Error::new(CliExit(cli_exit_code(code))));
81        }
82    };
83
84    // TeX source is the requested artifact — suppress only on *explicit* Quiet
85    // (ADR-0017 Amendment 2, same logic as `doiget text`).
86    if mode == OutputMode::Quiet && quiet_was_explicit {
87        return Ok(());
88    }
89
90    let stdout = std::io::stdout();
91    let mut out = stdout.lock();
92    if mode == OutputMode::Json {
93        let s = serde_json::to_string_pretty(&tex).context("serializing tex-source JSON")?;
94        writeln!(out, "{s}").context("writing tex-source JSON to stdout")?;
95        return Ok(());
96    }
97
98    render_human(&mut out, &tex)?;
99    Ok(())
100}
101
102fn render_human(out: &mut impl Write, tex: &PaperTexSource) -> Result<()> {
103    if let Some(f) = &tex.main_file {
104        writeln!(out, "% source: {f}").context("writing file header")?;
105    }
106    writeln!(out, "{}", tex.tex_source).context("writing tex source to stdout")?;
107    if tex.truncated {
108        print_err(format_args!(
109            "note: output truncated to {} chars (raise or drop --max-chars for the full source)",
110            tex.char_count
111        ));
112    }
113    Ok(())
114}
115
116#[cfg(test)]
117#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic, missing_docs)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn human_render_emits_file_header_and_source() {
123        let tex = PaperTexSource {
124            arxiv_id: "2401.12345".into(),
125            main_file: Some("main.tex".into()),
126            tex_source: "\\documentclass{article}".into(),
127            char_count: 23,
128            truncated: false,
129            retrieved_from: "https://export.arxiv.org/src/2401.12345".into(),
130        };
131        let mut buf: Vec<u8> = Vec::new();
132        render_human(&mut buf, &tex).expect("render");
133        let s = String::from_utf8(buf).expect("utf8");
134        assert!(s.contains("% source: main.tex"), "got: {s}");
135        assert!(s.contains("\\documentclass"), "got: {s}");
136    }
137
138    #[test]
139    fn json_envelope_has_expected_fields() {
140        let tex = PaperTexSource {
141            arxiv_id: "2401.12345".into(),
142            main_file: None,
143            tex_source: "\\documentclass{article}".into(),
144            char_count: 23,
145            truncated: false,
146            retrieved_from: "https://export.arxiv.org/src/2401.12345".into(),
147        };
148        let v = serde_json::to_value(&tex).expect("serialize");
149        assert_eq!(v["arxiv_id"], "2401.12345");
150        assert_eq!(v["truncated"], false);
151        assert_eq!(v["char_count"], 23);
152    }
153}