doiget_cli/commands/
text.rs1use std::io::Write;
21
22use anyhow::{Context, Result};
23
24use doiget_core::paper_text::{paper_text, PaperText, AR5IV_DEFAULT_BASE};
25use doiget_core::{ArxivId, ErrorCode, Ref};
26
27use super::fetch::{build_resolve_context, cli_exit_code, CliExit};
28use super::output::OutputMode;
29
30#[allow(clippy::print_stderr)]
33fn print_err(args: std::fmt::Arguments<'_>) {
34 eprintln!("{args}");
35}
36
37pub async fn run(
48 ref_: String,
49 max_chars: Option<usize>,
50 no_cache: bool,
51 mode: OutputMode,
52 quiet_was_explicit: bool,
53) -> Result<()> {
54 let parsed = Ref::parse(&ref_).with_context(|| format!("invalid ref {ref_:?}"))?;
55 let id: ArxivId = match parsed {
56 Ref::Arxiv(a) => a,
57 Ref::Doi(_) => {
58 let code = ErrorCode::NoOaAvailable;
62 print_err(format_args!(
63 "error[{}]: no full-text source for a DOI — if an arXiv preprint exists, \
64 pass its id (e.g. `doiget text arxiv:2401.12345`)",
65 code.as_wire()
66 ));
67 return Err(anyhow::Error::new(CliExit(cli_exit_code(code))));
68 }
69 };
70
71 let base = resolve_ar5iv_base()?;
72 let mut ctx = build_resolve_context().context("building fetch context")?;
76 if no_cache {
77 ctx.cache_root = None;
78 }
79
80 let text = match paper_text(&base, &id, max_chars, &ctx).await {
81 Ok(t) => t,
82 Err(e) => {
83 let code = ErrorCode::from(&e);
84 print_err(format_args!("error[{}]: {e}", code.as_wire()));
85 if code == ErrorCode::TextUnavailable {
91 print_err(format_args!(
92 " = note: the arXiv id is valid — fetch the PDF instead: `doiget fetch arxiv:{}`",
93 id.as_str()
94 ));
95 }
96 return Err(anyhow::Error::new(CliExit(cli_exit_code(code))));
97 }
98 };
99
100 if mode == OutputMode::Quiet && quiet_was_explicit {
105 return Ok(());
106 }
107
108 let stdout = std::io::stdout();
109 let mut out = stdout.lock();
110 if mode == OutputMode::Json {
111 let s = serde_json::to_string_pretty(&text).context("serializing paper text JSON")?;
112 writeln!(out, "{s}").context("writing paper text JSON to stdout")?;
113 return Ok(());
114 }
115
116 render_human(&mut out, &text)?;
117 Ok(())
118}
119
120fn resolve_ar5iv_base() -> Result<url::Url> {
123 let raw = std::env::var("DOIGET_AR5IV_BASE").unwrap_or_else(|_| AR5IV_DEFAULT_BASE.to_string());
124 url::Url::parse(&raw).with_context(|| format!("DOIGET_AR5IV_BASE is not a URL: {raw}"))
125}
126
127fn render_human(out: &mut impl Write, text: &PaperText) -> Result<()> {
131 if let Some(t) = &text.title {
132 writeln!(out, "# {t}").context("writing title to stdout")?;
133 }
134 for sec in &text.sections {
135 if let Some(h) = &sec.heading {
136 writeln!(out, "\n## {h}").context("writing section heading to stdout")?;
137 }
138 if !sec.text.is_empty() {
139 writeln!(out, "{}", sec.text).context("writing section body to stdout")?;
140 }
141 }
142 if text.truncated {
143 print_err(format_args!(
144 "note: output truncated to {} chars (raise or drop --max-chars for the full text)",
145 text.char_count
146 ));
147 }
148 Ok(())
149}
150
151#[cfg(test)]
152#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
153mod tests {
154 use super::*;
155 use doiget_core::paper_text::{TextSection, TextSource};
156
157 fn sample() -> PaperText {
158 PaperText {
159 arxiv_id: "2401.12345".into(),
160 source: TextSource::Ar5iv,
161 title: Some("A Title".into()),
162 sections: vec![
163 TextSection {
164 heading: None,
165 text: "Lead paragraph.".into(),
166 },
167 TextSection {
168 heading: Some("1 Introduction".into()),
169 text: "Body text.".into(),
170 },
171 ],
172 char_count: 25,
173 truncated: false,
174 retrieved_from: "https://ar5iv.labs.arxiv.org/html/2401.12345".into(),
175 }
176 }
177
178 #[test]
179 fn json_envelope_is_the_paper_text_shape() {
180 let v = serde_json::to_value(sample()).expect("serialize");
181 assert_eq!(v["arxiv_id"], "2401.12345");
182 assert_eq!(v["source"], "ar5iv");
183 assert_eq!(v["title"], "A Title");
184 assert_eq!(v["sections"][1]["heading"], "1 Introduction");
185 assert_eq!(v["truncated"], false);
186 }
187
188 #[test]
189 fn human_render_lays_out_title_and_sections() {
190 let mut buf: Vec<u8> = Vec::new();
191 render_human(&mut buf, &sample()).expect("render");
192 let s = String::from_utf8(buf).expect("utf8");
193 assert!(s.contains("# A Title"), "got: {s}");
194 assert!(s.contains("## 1 Introduction"), "got: {s}");
195 assert!(s.contains("Lead paragraph."), "got: {s}");
196 assert!(s.contains("Body text."), "got: {s}");
197 }
198
199 #[test]
200 fn resolve_ar5iv_base_defaults_to_production() {
201 if std::env::var("DOIGET_AR5IV_BASE").is_err() {
205 let u = resolve_ar5iv_base().expect("base");
206 assert_eq!(u.as_str(), "https://ar5iv.labs.arxiv.org/");
207 }
208 }
209}