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
22pub 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#[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#[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 pub patents: bool,
76}
77
78impl SearchQuery {
79 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 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#[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 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 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 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#[derive(Debug, Clone, Copy)]
205pub struct Capabilities {
206 pub search: Option<SearchCaps>,
208 pub get: bool,
209 pub download: bool,
210 pub cite: bool,
212 pub max_limit: Option<u32>,
214 pub notes: &'static str,
216}
217
218pub 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
237pub 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#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
257pub enum Direction {
258 Incoming,
260 Outgoing,
262}
263
264#[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}