Skip to main content

fastpaper/commands/
get.rs

1use 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
28/// Pick the source that owns this identifier shape.
29fn 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
68/// Strip the disambiguating prefixes that only exist to help `detect_id_type`;
69/// the APIs themselves want the bare identifier.
70fn 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}