Skip to main content

doiget_cli/commands/
link.rs

1//! `doiget link <doi>` — resolve a DOI to its arXiv preprint and identity
2//! cluster (#281 item 5: arXiv ↔ published-DOI linking & dedup).
3//!
4//! Given a published **DOI**, this reports whether the same work has a free
5//! **arXiv preprint** (plus the OpenAlex id and title), so an agent can read
6//! the free full text (`doiget text arxiv:<id>`) or dedup a preprint against
7//! its journal version without fetching both.
8//!
9//! Backed by [`doiget_core::discovery::resolve_links_for_doi`] over OpenAlex
10//! (`/works?filter=doi:`): Tier-1 OA metadata, always-on; never fetches a
11//! PDF. arXiv → DOI (the reverse direction) is a planned follow-up; a
12//! non-DOI ref is rejected.
13
14use std::io::Write;
15
16use anyhow::{Context, Result};
17
18use doiget_core::discovery::{resolve_links_for_doi, PaperLinks};
19use doiget_core::{ErrorCode, Ref};
20
21use super::fetch::{build_resolve_context, cli_exit_code, render_fetch_error, CliExit};
22use super::output::OutputMode;
23
24/// Production OpenAlex API base. Overridable via `DOIGET_OPENALEX_BASE`
25/// (test wiremock origin), mirroring the `search` subcommand.
26const OPENALEX_DEFAULT_BASE: &str = "https://api.openalex.org";
27
28/// Run the `link` subcommand.
29///
30/// # Errors
31///
32/// A non-DOI ref is a usage error; OpenAlex failures surface a typed
33/// [`ErrorCode`] as a process exit code via [`CliExit`] (e.g. a DOI with no
34/// OpenAlex work → `NOT_FOUND`).
35pub async fn run(ref_: String, mode: OutputMode, quiet_was_explicit: bool) -> Result<()> {
36    let parsed = super::parse_ref_or_exit(&ref_)?;
37    let doi = match parsed {
38        Ref::Doi(d) => d,
39        Ref::Arxiv(_) => {
40            anyhow::bail!(
41                "`doiget link` resolves a DOI to its arXiv preprint; \
42                 arXiv → DOI linking is a follow-up (#281). Pass a DOI."
43            );
44        }
45    };
46
47    let base = resolve_openalex_base()?;
48    // Omit `mailto` when no contact email is configured (never a
49    // placeholder); the empty string is skipped downstream. Resolved
50    // through the core ladder so config.toml's rung counts (#504).
51    let contact_email = doiget_core::orchestrator::configured_contact_email().unwrap_or_default();
52    let ctx = build_resolve_context().context("building fetch context")?;
53
54    let links = match resolve_links_for_doi(&base, &contact_email, doi.as_str(), &ctx).await {
55        Ok(l) => l,
56        Err(e) => {
57            // Route through the shared renderer so a denial-class failure
58            // (e.g. an off-allowlist OpenAlex redirect) carries its ADR-0023
59            // `= note:` line, single-sourced with the other commands (#287).
60            render_fetch_error(&e);
61            return Err(anyhow::Error::new(CliExit(cli_exit_code(ErrorCode::from(
62                &e,
63            )))));
64        }
65    };
66
67    // Artifact-class (ADR-0017 Amendment 2 / #301): suppress only on
68    // explicit Quiet; the non-TTY implicit fallback still emits.
69    if mode == OutputMode::Quiet && quiet_was_explicit {
70        return Ok(());
71    }
72
73    let stdout = std::io::stdout();
74    let mut out = stdout.lock();
75    if mode == OutputMode::Json {
76        let s = serde_json::to_string_pretty(&links).context("serializing link JSON")?;
77        writeln!(out, "{s}").context("writing link JSON to stdout")?;
78        return Ok(());
79    }
80
81    render_human(&mut out, &links)?;
82    Ok(())
83}
84
85/// Resolve the OpenAlex base URL: `DOIGET_OPENALEX_BASE` override (tests) or
86/// the production default.
87fn resolve_openalex_base() -> Result<url::Url> {
88    let raw =
89        std::env::var("DOIGET_OPENALEX_BASE").unwrap_or_else(|_| OPENALEX_DEFAULT_BASE.to_string());
90    url::Url::parse(&raw).with_context(|| format!("DOIGET_OPENALEX_BASE is not a URL: {raw}"))
91}
92
93/// Render the identity cluster in human mode. The arXiv line is the
94/// load-bearing signal (present preprint → readable / dedup target).
95fn render_human(out: &mut impl Write, links: &PaperLinks) -> Result<()> {
96    let arxiv = match &links.arxiv {
97        Some(a) => a.as_str(),
98        None => "-  (no arXiv preprint found)",
99    };
100    writeln!(out, "doi:      {}", links.doi.as_deref().unwrap_or("-"))
101        .context("writing doi line")?;
102    writeln!(out, "arxiv:    {arxiv}").context("writing arxiv line")?;
103    writeln!(out, "openalex: {}", links.openalex_id).context("writing openalex line")?;
104    writeln!(out, "title:    {}", links.title).context("writing title line")?;
105    Ok(())
106}
107
108#[cfg(test)]
109#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
110mod tests {
111    use super::*;
112
113    fn sample() -> PaperLinks {
114        PaperLinks {
115            doi: Some("10.1103/physrevb.1".into()),
116            arxiv: Some("2101.54321v2".into()),
117            openalex_id: "W55".into(),
118            title: "Published Version".into(),
119        }
120    }
121
122    #[test]
123    fn json_output_is_the_paper_links_shape() {
124        let v = serde_json::to_value(sample()).expect("serialize");
125        assert_eq!(v["doi"], "10.1103/physrevb.1");
126        assert_eq!(v["arxiv"], "2101.54321v2");
127        assert_eq!(v["openalex_id"], "W55");
128        assert_eq!(v["title"], "Published Version");
129    }
130
131    #[test]
132    fn human_render_shows_arxiv_and_placeholder() {
133        let mut buf: Vec<u8> = Vec::new();
134        render_human(&mut buf, &sample()).expect("render");
135        let s = String::from_utf8(buf).expect("utf8");
136        assert!(s.contains("arxiv:    2101.54321v2"), "got: {s}");
137
138        let mut none = sample();
139        none.arxiv = None;
140        let mut buf2: Vec<u8> = Vec::new();
141        render_human(&mut buf2, &none).expect("render");
142        let s2 = String::from_utf8(buf2).expect("utf8");
143        assert!(s2.contains("no arXiv preprint found"), "got: {s2}");
144    }
145
146    #[tokio::test]
147    async fn link_rejects_arxiv_input() {
148        let err = run("arxiv:2401.12345".to_string(), OutputMode::Quiet, true)
149            .await
150            .expect_err("arXiv input must be a usage error");
151        assert!(err.to_string().contains("Pass a DOI"), "got: {err}");
152    }
153}