Skip to main content

fastpaper/sources/
mod.rs

1pub mod arxiv;
2pub mod biorxiv;
3pub mod core;
4pub mod crossref;
5pub mod dblp;
6pub mod doaj;
7pub mod europepmc;
8pub mod hal;
9pub mod medrxiv;
10pub mod openaire;
11pub mod openalex;
12pub mod pmc;
13pub mod pubmed;
14pub mod scholar;
15pub mod semantic;
16pub mod unpaywall;
17pub mod xueshu;
18pub mod zenodo;
19
20use serde::Serialize;
21
22/// Percent-encode a query string for use in URLs.
23pub fn encode_query(query: &str) -> String {
24    let mut encoded = String::with_capacity(query.len() * 3);
25    for byte in query.bytes() {
26        match byte {
27            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
28                encoded.push(byte as char);
29            }
30            b' ' => encoded.push('+'),
31            _ => {
32                encoded.push('%');
33                encoded.push_str(&format!("{:02X}", byte));
34            }
35        }
36    }
37    encoded
38}
39
40/// Field to sort search results by.
41#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq)]
42pub enum SortField {
43    Relevance,
44    Date,
45    Citations,
46}
47
48#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq)]
49pub enum SortOrder {
50    Asc,
51    Desc,
52}
53
54/// A normalized search request.
55///
56/// Each source maps the fields it supports onto its own API parameters. The
57/// command layer consults `Capabilities` first and rejects any field the source
58/// cannot honour, so a filter is never silently dropped.
59#[derive(Debug, Clone)]
60pub struct SearchQuery {
61    pub query: String,
62    pub limit: u32,
63    pub offset: u32,
64    pub sort: Option<SortField>,
65    pub order: SortOrder,
66    pub year: Option<u16>,
67    pub after: Option<String>,
68    pub before: Option<String>,
69    pub author: Option<String>,
70    pub field: Option<String>,
71    pub open_access: bool,
72    /// Return patents only. Sources that can honour it filter to the patent
73    /// subset; without it they exclude patents. Results are never mixed, so a
74    /// caller always knows which it asked for.
75    pub patents: bool,
76}
77
78impl SearchQuery {
79    /// A query with no filters — what every source could already do.
80    pub fn simple(query: &str, limit: u32) -> Self {
81        SearchQuery {
82            query: query.to_string(),
83            limit,
84            offset: 0,
85            sort: None,
86            order: SortOrder::Desc,
87            year: None,
88            after: None,
89            before: None,
90            author: None,
91            field: None,
92            open_access: false,
93            patents: false,
94        }
95    }
96
97    /// Names of the filters this query actually sets, in CLI-flag form.
98    pub fn active_filters(&self) -> Vec<&'static str> {
99        let mut used = Vec::new();
100        if self.offset > 0 {
101            used.push("--offset");
102        }
103        if self.sort.is_some() {
104            used.push("--sort");
105        }
106        if self.year.is_some() {
107            used.push("--year");
108        }
109        if self.after.is_some() {
110            used.push("--after");
111        }
112        if self.before.is_some() {
113            used.push("--before");
114        }
115        if self.author.is_some() {
116            used.push("--author");
117        }
118        if self.field.is_some() {
119            used.push("--field");
120        }
121        if self.open_access {
122            used.push("--open-access");
123        }
124        if self.patents {
125            used.push("--patents");
126        }
127        used
128    }
129}
130
131/// Which search filters a source can honour natively.
132#[derive(Debug, Clone, Copy, Default)]
133pub struct SearchCaps {
134    pub offset: bool,
135    pub sort: bool,
136    pub year: bool,
137    pub date_range: bool,
138    pub author: bool,
139    pub field: bool,
140    pub open_access: bool,
141    pub patents: bool,
142}
143
144impl SearchCaps {
145    /// Query and limit only — no filters.
146    pub const BASIC: SearchCaps = SearchCaps {
147        offset: false,
148        sort: false,
149        year: false,
150        date_range: false,
151        author: false,
152        field: false,
153        open_access: false,
154        patents: false,
155    };
156
157    /// Whether this source supports the named CLI flag.
158    pub fn supports(&self, flag: &str) -> bool {
159        match flag {
160            "--offset" => self.offset,
161            "--sort" => self.sort,
162            "--year" => self.year,
163            "--after" | "--before" => self.date_range,
164            "--author" => self.author,
165            "--field" => self.field,
166            "--open-access" => self.open_access,
167            "--patents" => self.patents,
168            _ => false,
169        }
170    }
171
172    /// The flags this source does support, for use in error messages.
173    pub fn supported_flags(&self) -> Vec<&'static str> {
174        let mut flags = Vec::new();
175        if self.offset {
176            flags.push("--offset");
177        }
178        if self.sort {
179            flags.push("--sort");
180        }
181        if self.year {
182            flags.push("--year");
183        }
184        if self.date_range {
185            flags.push("--after/--before");
186        }
187        if self.author {
188            flags.push("--author");
189        }
190        if self.field {
191            flags.push("--field");
192        }
193        if self.open_access {
194            flags.push("--open-access");
195        }
196        if self.patents {
197            flags.push("--patents");
198        }
199        flags
200    }
201}
202
203/// What a source can do. Drives both argument validation and `fastpaper sources`.
204#[derive(Debug, Clone, Copy)]
205pub struct Capabilities {
206    /// `None` when the source has no keyword search at all.
207    pub search: Option<SearchCaps>,
208    pub get: bool,
209    pub download: bool,
210    /// Can walk citation edges in both directions.
211    pub cite: bool,
212    /// Hard per-request result cap imposed by the source, if any.
213    pub max_limit: Option<u32>,
214    /// Caveat shown by `fastpaper sources --capabilities`; empty when none.
215    pub notes: &'static str,
216}
217
218/// Check that a `--after` / `--before` value is a plain `YYYY-MM-DD` date.
219///
220/// Sources reshape it into whatever their API wants, but they all reject the
221/// same malformed input, and they should reject it before the request goes out
222/// rather than passing it along for the server to misinterpret.
223pub fn validate_ymd(date: &str) -> Result<&str, String> {
224    let parts: Vec<&str> = date.split('-').collect();
225    let ok = parts.len() == 3
226        && parts[0].len() == 4
227        && parts[1].len() == 2
228        && parts[2].len() == 2
229        && parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit()));
230    if ok {
231        Ok(date)
232    } else {
233        Err(format!("Invalid date '{}': expected YYYY-MM-DD", date))
234    }
235}
236
237/// Contact address used to identify this client to APIs that ask for one
238/// (Crossref's polite pool, OpenAlex, NCBI E-utilities).
239///
240/// Returns `None` unless the user sets `FASTPAPER_EMAIL`. Sending a third
241/// party's address on the user's behalf misattributes the traffic and risks
242/// getting that address throttled, so the parameter is simply omitted when
243/// the user has not supplied one — every such API treats it as optional.
244/// Unpaywall is the exception: it *requires* an address and errors without one.
245pub fn contact_email() -> Option<String> {
246    std::env::var("FASTPAPER_EMAIL")
247        .ok()
248        .map(|s| s.trim().to_string())
249        .filter(|s| !s.is_empty())
250}
251
252/// Which way along a citation edge to walk.
253///
254/// Naming is deliberately about direction rather than the words "citations"
255/// and "references", which flip meaning depending on who is speaking.
256#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
257pub enum Direction {
258    /// Papers that cite this one — what the field did next.
259    Incoming,
260    /// Papers this one cites — what it was built on.
261    Outgoing,
262}
263
264/// A paper returned from any source.
265#[derive(Debug, Clone, Serialize)]
266pub struct Paper {
267    pub id: String,
268    pub title: String,
269    pub authors: Vec<String>,
270    #[serde(rename = "abstract")]
271    pub abstract_text: Option<String>,
272    pub year: Option<u16>,
273    pub doi: Option<String>,
274    pub url: Option<String>,
275    pub pdf_url: Option<String>,
276    pub venue: Option<String>,
277    pub citations: Option<u32>,
278    pub fields: Vec<String>,
279    pub open_access: Option<bool>,
280    pub source: String,
281}
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn a_simple_query_asks_for_no_patents() {
288        assert!(!SearchQuery::simple("attention", 10).patents);
289    }
290
291    #[test]
292    fn setting_patents_shows_up_as_an_active_filter() {
293        let mut q = SearchQuery::simple("attention", 10);
294        q.patents = true;
295        assert!(q.active_filters().contains(&"--patents"));
296    }
297
298    #[test]
299    fn patents_is_not_a_basic_capability() {
300        assert!(!SearchCaps::BASIC.supports("--patents"));
301    }
302
303    #[test]
304    fn a_source_declaring_patents_supports_the_flag() {
305        let caps = SearchCaps {
306            patents: true,
307            ..SearchCaps::BASIC
308        };
309        assert!(caps.supports("--patents"));
310        assert!(caps.supported_flags().contains(&"--patents"));
311    }
312}