Skip to main content

doiget_cli/commands/
text.rs

1//! `doiget text <ref>` — extract a paper's full text (the #281 "read"
2//! step; ADR-0032).
3//!
4//! Fetches the ar5iv LaTeXML-XHTML rendering of an **arXiv** paper and
5//! emits it as sectioned plain text — the read step of the agent research
6//! loop, without an external pdf-to-text tool. The PDF blob is never
7//! opened (ADR-0032 D1).
8//!
9//! - **arXiv id** → ar5iv extraction via
10//!   [`doiget_core::paper_text::paper_text`].
11//! - **DOI** → a structured `NO_OA_AVAILABLE` ("pass the arXiv id"):
12//!   DOI→arXiv resolution is #281 item 5 (ADR-0032 D5).
13//!
14//! `--max-chars N` caps the returned text (truncation is flagged, never
15//! silent); `--no-cache` bypasses the on-disk text cache. `--mode json`
16//! emits the [`PaperText`] structure; the human mode renders a
17//! Markdown-ish title + section layout. Tier-1 OA metadata, always-on —
18//! ships in the default `oa-only` binary (ADR-0032 D2).
19
20use 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::print_err;
29use super::output::OutputMode;
30
31/// Run the `text` subcommand.
32///
33/// # Errors
34///
35/// Surfaces a typed [`ErrorCode`] as a process exit code via
36/// [`CliExit`]: an invalid ref is a usage error; a DOI yields
37/// `NO_OA_AVAILABLE`; an ar5iv render with no extractable prose yields
38/// `TEXT_UNAVAILABLE` (never a silent exit-0 — issue #302) with an
39/// actionable "fetch the PDF" note; other extraction failures map through
40/// [`ErrorCode::from`].
41pub async fn run(
42    ref_: String,
43    max_chars: Option<usize>,
44    no_cache: bool,
45    mode: OutputMode,
46    quiet_was_explicit: bool,
47) -> Result<()> {
48    let parsed = super::parse_ref_or_exit(&ref_)?;
49    let id: ArxivId = match parsed {
50        Ref::Arxiv(a) => a,
51        Ref::Doi(_) => {
52            // No full-text HTML source for a bare DOI in PR4; DOI→arXiv
53            // linking is #281 item 5 (ADR-0032 D5). Report honestly rather
54            // than silently failing.
55            let code = ErrorCode::NoOaAvailable;
56            print_err(format_args!(
57                "error[{}]: no full-text source for a DOI — if an arXiv preprint exists, \
58                 pass its id (e.g. `doiget text arxiv:2401.12345`)",
59                code.as_wire()
60            ));
61            return Err(anyhow::Error::new(CliExit(cli_exit_code(code))));
62        }
63    };
64
65    let base = resolve_ar5iv_base()?;
66    // `text` is a read-only resolve command (like `cite` / `verify`), so it
67    // reuses the resolve context — which enables the on-disk cache root
68    // (`docs/CACHE.md`) that `paper_text` consults for the text cache.
69    let mut ctx = build_resolve_context().context("building fetch context")?;
70    if no_cache {
71        ctx.cache_root = None;
72    }
73
74    let text = match paper_text(&base, &id, max_chars, &ctx).await {
75        Ok(t) => t,
76        Err(e) => {
77            let code = ErrorCode::from(&e);
78            print_err(format_args!("error[{}]: {e}", code.as_wire()));
79            // `text unavailable` is the one read-step failure with a
80            // concrete next action: the id is valid and the PDF may well be
81            // fetchable, so spell the exact command out (issue #302) rather
82            // than leave the agent to infer it. Mirrors the `= note:` line
83            // `render_fetch_error` attaches to denial-class failures.
84            if code == ErrorCode::TextUnavailable {
85                print_err(format_args!(
86                    "  = note: the arXiv id is valid — fetch the PDF instead: `doiget fetch arxiv:{}`",
87                    id.as_str()
88                ));
89            }
90            return Err(anyhow::Error::new(CliExit(cli_exit_code(code))));
91        }
92    };
93
94    // Extracted paper prose IS the requested artifact (like `bib` / `info`),
95    // so an *implicit* non-TTY Quiet (e.g. `doiget text arxiv:… > paper.txt`)
96    // must NOT swallow it — only an explicit `--quiet` / `DOIGET_MODE=quiet`
97    // does. This is ADR-0017 Amendment 2 (#301), extended to `text`.
98    if mode == OutputMode::Quiet && quiet_was_explicit {
99        return Ok(());
100    }
101
102    let stdout = std::io::stdout();
103    let mut out = stdout.lock();
104    if mode == OutputMode::Json {
105        let s = serde_json::to_string_pretty(&text).context("serializing paper text JSON")?;
106        writeln!(out, "{s}").context("writing paper text JSON to stdout")?;
107        return Ok(());
108    }
109
110    render_human(&mut out, &text)?;
111    Ok(())
112}
113
114/// Resolve the ar5iv base URL: `DOIGET_AR5IV_BASE` override (tests) or the
115/// production default.
116fn resolve_ar5iv_base() -> Result<url::Url> {
117    let raw = std::env::var("DOIGET_AR5IV_BASE").unwrap_or_else(|_| AR5IV_DEFAULT_BASE.to_string());
118    url::Url::parse(&raw).with_context(|| format!("DOIGET_AR5IV_BASE is not a URL: {raw}"))
119}
120
121/// Render extracted text in human mode: a Markdown-ish title + section
122/// layout. The truncation note (when applicable) goes to stderr so it does
123/// not pollute the piped text body on stdout.
124fn render_human(out: &mut impl Write, text: &PaperText) -> Result<()> {
125    if let Some(t) = &text.title {
126        writeln!(out, "# {t}").context("writing title to stdout")?;
127    }
128    for sec in &text.sections {
129        if let Some(h) = &sec.heading {
130            writeln!(out, "\n## {h}").context("writing section heading to stdout")?;
131        }
132        if !sec.text.is_empty() {
133            writeln!(out, "{}", sec.text).context("writing section body to stdout")?;
134        }
135    }
136    if text.truncated {
137        print_err(format_args!(
138            "note: output truncated to {} chars (raise or drop --max-chars for the full text)",
139            text.char_count
140        ));
141    }
142    Ok(())
143}
144
145#[cfg(test)]
146#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
147mod tests {
148    use super::*;
149    use doiget_core::paper_text::{TextSection, TextSource};
150
151    fn sample() -> PaperText {
152        PaperText {
153            arxiv_id: "2401.12345".into(),
154            source: TextSource::Ar5iv,
155            title: Some("A Title".into()),
156            sections: vec![
157                TextSection {
158                    heading: None,
159                    text: "Lead paragraph.".into(),
160                },
161                TextSection {
162                    heading: Some("1 Introduction".into()),
163                    text: "Body text.".into(),
164                },
165            ],
166            char_count: 25,
167            truncated: false,
168            retrieved_from: "https://ar5iv.labs.arxiv.org/html/2401.12345".into(),
169        }
170    }
171
172    #[test]
173    fn json_envelope_is_the_paper_text_shape() {
174        let v = serde_json::to_value(sample()).expect("serialize");
175        assert_eq!(v["arxiv_id"], "2401.12345");
176        assert_eq!(v["source"], "ar5iv");
177        assert_eq!(v["title"], "A Title");
178        assert_eq!(v["sections"][1]["heading"], "1 Introduction");
179        assert_eq!(v["truncated"], false);
180    }
181
182    #[test]
183    fn human_render_lays_out_title_and_sections() {
184        let mut buf: Vec<u8> = Vec::new();
185        render_human(&mut buf, &sample()).expect("render");
186        let s = String::from_utf8(buf).expect("utf8");
187        assert!(s.contains("# A Title"), "got: {s}");
188        assert!(s.contains("## 1 Introduction"), "got: {s}");
189        assert!(s.contains("Lead paragraph."), "got: {s}");
190        assert!(s.contains("Body text."), "got: {s}");
191    }
192
193    #[test]
194    fn resolve_ar5iv_base_defaults_to_production() {
195        // With no override set, the default base must be the production
196        // ar5iv host. (Serial-free: only asserts the default branch when
197        // the env var is absent.)
198        if std::env::var("DOIGET_AR5IV_BASE").is_err() {
199            let u = resolve_ar5iv_base().expect("base");
200            assert_eq!(u.as_str(), "https://ar5iv.labs.arxiv.org/");
201        }
202    }
203}