fastpaper/commands/
get.rs1use super::{CommandError, CommandResult, emit, failed, render};
2use crate::cli::{GetArgs, GlobalOpts};
3use crate::identifier::{IdType, detect_id_type};
4use crate::registry::Source;
5
6pub fn run(args: &GetArgs, global: &GlobalOpts) -> CommandResult {
7 let (explicit, raw_id) = args.resolve().map_err(CommandError::Failed)?;
8
9 let source = match explicit {
10 Some(source) => source,
11 None => route(raw_id)?,
12 };
13
14 let entry = source.entry();
15 let get = entry.get.ok_or_else(|| unsupported_get(source))?;
16
17 let id = normalize_id(source, raw_id);
18 match get(&source.base_url(), &id).map_err(CommandError::Failed)? {
19 Some(paper) => emit(&render(&[paper], global.format), None),
20 None => Err(CommandError::NotFound(format!(
21 "Paper not found in {}: {}",
22 source.name(),
23 raw_id
24 ))),
25 }
26}
27
28fn route(id: &str) -> Result<Source, CommandError> {
30 match detect_id_type(id) {
31 IdType::Arxiv | IdType::ArxivOld => Ok(Source::Arxiv),
32 IdType::Doi => Ok(Source::Crossref),
33 IdType::Pmc => Ok(Source::Pmc),
34 IdType::Pmid => Ok(Source::Pubmed),
35 IdType::S2 => Ok(Source::Semantic),
36 IdType::Url => Err(failed(format!(
37 "Cannot look up a URL directly: {}\nPass the DOI or arXiv ID instead, or name a source: fastpaper get <source> <id>",
38 id
39 ))),
40 IdType::Unknown => Err(failed(format!(
41 "Unrecognized identifier: '{}'\nExpected an arXiv ID, DOI, PMC ID, PMID or S2 ID.\nTo search by keyword instead: fastpaper search <source> \"{}\"",
42 id, id
43 ))),
44 }
45}
46
47fn unsupported_get(source: Source) -> CommandError {
48 let hint = match source {
49 Source::Openalex
50 | Source::Dblp
51 | Source::Openaire
52 | Source::Doaj
53 | Source::Zenodo
54 | Source::Hal
55 | Source::Core
56 | Source::Europepmc => {
57 "\nTry a source that resolves identifiers: crossref (DOI), arxiv, pubmed, pmc, semantic."
58 }
59 _ => "",
60 };
61 failed(format!(
62 "'{}' cannot fetch a paper by identifier.{}",
63 source.name(),
64 hint
65 ))
66}
67
68fn normalize_id(source: Source, id: &str) -> String {
71 match source {
72 Source::Pubmed => id.strip_prefix("PMID:").unwrap_or(id).to_string(),
73 Source::Semantic => id.strip_prefix("S2:").unwrap_or(id).to_string(),
74 _ => id.to_string(),
75 }
76}
77
78#[cfg(test)]
79mod tests {
80 use super::*;
81
82 #[test]
83 fn arxiv_id_routes_to_arxiv() {
84 assert_eq!(route("2301.08745").unwrap(), Source::Arxiv);
85 assert_eq!(route("hep-th/9711200").unwrap(), Source::Arxiv);
86 }
87
88 #[test]
89 fn doi_routes_to_crossref() {
90 assert_eq!(route("10.1038/nature12373").unwrap(), Source::Crossref);
91 }
92
93 #[test]
94 fn pmc_id_routes_to_pmc() {
95 assert_eq!(route("PMC7318926").unwrap(), Source::Pmc);
96 }
97
98 #[test]
99 fn pmid_routes_to_pubmed() {
100 assert_eq!(route("PMID:33475315").unwrap(), Source::Pubmed);
101 assert_eq!(route("33475315").unwrap(), Source::Pubmed);
102 }
103
104 #[test]
105 fn s2_id_routes_to_semantic() {
106 assert_eq!(route("S2:abc123def").unwrap(), Source::Semantic);
107 }
108
109 #[test]
110 fn url_is_rejected_with_advice() {
111 let err = route("https://arxiv.org/abs/2301.08745").unwrap_err();
112 assert!(
113 err.message().contains("DOI or arXiv ID"),
114 "got: {}",
115 err.message()
116 );
117 }
118
119 #[test]
120 fn unknown_identifier_suggests_search() {
121 let err = route("attention is all you need").unwrap_err();
122 assert!(
123 err.message().contains("fastpaper search"),
124 "a phrase is probably a search: {}",
125 err.message()
126 );
127 }
128
129 #[test]
130 fn pmid_prefix_is_stripped_for_the_api() {
131 assert_eq!(normalize_id(Source::Pubmed, "PMID:33475315"), "33475315");
132 assert_eq!(normalize_id(Source::Pubmed, "33475315"), "33475315");
133 }
134
135 #[test]
136 fn s2_prefix_is_stripped_for_the_api() {
137 assert_eq!(normalize_id(Source::Semantic, "S2:abc123"), "abc123");
138 }
139
140 #[test]
141 fn other_sources_keep_the_identifier_verbatim() {
142 assert_eq!(
143 normalize_id(Source::Crossref, "10.1038/nature12373"),
144 "10.1038/nature12373"
145 );
146 assert_eq!(normalize_id(Source::Pmc, "PMC7318926"), "PMC7318926");
147 }
148
149 #[test]
150 fn metadata_only_sources_get_a_useful_error() {
151 let err = unsupported_get(Source::Openalex);
152 assert!(err.message().contains("openalex"), "got: {}", err.message());
153 assert!(err.message().contains("crossref"), "got: {}", err.message());
154 }
155}