doiget_cli/commands/
tex_source.rs1use 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
28pub 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 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}