doiget_cli/commands/
link.rs1use 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
24const OPENALEX_DEFAULT_BASE: &str = "https://api.openalex.org";
27
28pub 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 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 render_fetch_error(&e);
61 return Err(anyhow::Error::new(CliExit(cli_exit_code(ErrorCode::from(
62 &e,
63 )))));
64 }
65 };
66
67 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
85fn 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
93fn 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}