Skip to main content

fastpaper/sources/
hal.rs

1use super::Paper;
2
3const FIELDS: &str = "halId_s,title_s,authFullName_s,abstract_s,doiId_s,publicationDateY_i,fileMain_s,uri_s";
4
5/// Search HAL API.
6pub fn search(base_url: &str, query: &str, max_results: u32) -> Result<Vec<Paper>, String> {
7    let encoded = super::encode_query(query);
8    let url = format!(
9        "{}/search/?q={}&rows={}&wt=json&fl={}",
10        base_url, encoded, max_results, FIELDS
11    );
12
13    let mut last_err = String::new();
14    for attempt in 0..3 {
15        if attempt > 0 {
16            std::thread::sleep(std::time::Duration::from_millis(100 * (1 << attempt)));
17        }
18        match ureq::get(&url).call() {
19            Ok(resp) => {
20                let body = resp
21                    .into_body()
22                    .read_to_string()
23                    .map_err(|e| format!("Failed to read response: {}", e))?;
24                return parse_search_response(&body);
25            }
26            Err(ureq::Error::StatusCode(429)) => {
27                last_err = "rate limited (429)".to_string();
28                continue;
29            }
30            Err(ureq::Error::StatusCode(code)) if code >= 500 => {
31                return Err(format!("Server error: {}", code));
32            }
33            Err(e) => {
34                return Err(format!("HTTP error: {}", e));
35            }
36        }
37    }
38    Err(last_err)
39}
40
41/// Parse HAL JSON search response into a list of Papers.
42pub fn parse_search_response(json: &str) -> Result<Vec<Paper>, String> {
43    let root: serde_json::Value =
44        serde_json::from_str(json).map_err(|e| format!("JSON parse error: {}", e))?;
45
46    let docs = root["response"]["docs"]
47        .as_array()
48        .ok_or("missing 'response.docs' array")?;
49
50    let mut papers = Vec::new();
51    for item in docs {
52        // title_s can be array or string
53        let title = if let Some(arr) = item["title_s"].as_array() {
54            arr.first().and_then(|v| v.as_str()).unwrap_or("").to_string()
55        } else {
56            item["title_s"].as_str().unwrap_or("").to_string()
57        };
58        if title.is_empty() {
59            continue;
60        }
61
62        let id = item["halId_s"].as_str().unwrap_or("").to_string();
63
64        let authors: Vec<String> = item["authFullName_s"]
65            .as_array()
66            .map(|arr| {
67                arr.iter()
68                    .filter_map(|a| a.as_str().map(|s| s.to_string()))
69                    .collect()
70            })
71            .unwrap_or_default();
72
73        let doi = item["doiId_s"].as_str().map(|s| s.to_string());
74
75        // abstract_s can be array or string
76        let abstract_text = if let Some(arr) = item["abstract_s"].as_array() {
77            arr.first().and_then(|v| v.as_str()).map(|s| s.to_string())
78        } else {
79            item["abstract_s"].as_str().map(|s| s.to_string())
80        };
81
82        let year = item["publicationDateY_i"].as_u64().map(|y| y as u16);
83
84        let pdf_url = item["fileMain_s"].as_str().map(|s| s.to_string());
85
86        papers.push(Paper {
87            id,
88            title,
89            authors,
90            abstract_text,
91            year,
92            doi,
93            url: item["uri_s"].as_str().map(|s| s.to_string()),
94            pdf_url,
95            venue: None,
96            citations: None,
97            fields: vec![],
98            open_access: Some(true),
99            source: "hal".to_string(),
100        });
101    }
102
103    Ok(papers)
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    const FIXTURE: &str = include_str!("../../tests/fixtures/hal_search.json");
111
112    #[test]
113    fn parse_returns_ok() {
114        let result = parse_search_response(FIXTURE);
115        assert!(result.is_ok());
116    }
117
118    #[test]
119    fn parse_papers_not_empty() {
120        let papers = parse_search_response(FIXTURE).unwrap();
121        assert!(!papers.is_empty());
122    }
123
124    #[test]
125    fn parse_source_is_hal() {
126        let papers = parse_search_response(FIXTURE).unwrap();
127        for p in &papers {
128            assert_eq!(p.source, "hal");
129        }
130    }
131
132    #[test]
133    fn parse_id_is_hal_format() {
134        let papers = parse_search_response(FIXTURE).unwrap();
135        for p in &papers {
136            assert!(p.id.starts_with("hal-"), "id should start with hal-: {}", p.id);
137        }
138    }
139
140    #[test]
141    fn parse_title() {
142        let papers = parse_search_response(FIXTURE).unwrap();
143        for p in &papers {
144            assert!(!p.title.is_empty(), "paper {} has empty title", p.id);
145        }
146    }
147
148    #[test]
149    fn parse_authors() {
150        let papers = parse_search_response(FIXTURE).unwrap();
151        let with_authors: Vec<_> = papers.iter().filter(|p| !p.authors.is_empty()).collect();
152        assert!(!with_authors.is_empty(), "no papers with authors");
153    }
154
155    #[test]
156    fn parse_doi() {
157        let papers = parse_search_response(FIXTURE).unwrap();
158        let with_doi: Vec<_> = papers.iter().filter(|p| p.doi.is_some()).collect();
159        assert!(!with_doi.is_empty(), "no papers with DOI");
160        for p in &with_doi {
161            assert!(p.doi.as_ref().unwrap().starts_with("10."));
162        }
163    }
164
165    #[test]
166    fn parse_abstract() {
167        let papers = parse_search_response(FIXTURE).unwrap();
168        let with_abstract: Vec<_> = papers.iter().filter(|p| p.abstract_text.is_some()).collect();
169        assert!(!with_abstract.is_empty(), "no papers with abstract");
170    }
171
172    #[test]
173    fn parse_year() {
174        let papers = parse_search_response(FIXTURE).unwrap();
175        let with_year: Vec<_> = papers.iter().filter(|p| p.year.is_some()).collect();
176        assert!(!with_year.is_empty(), "no papers with year");
177        for p in &with_year {
178            assert!(p.year.unwrap() >= 2000);
179        }
180    }
181
182    #[test]
183    fn parse_pdf_url() {
184        let papers = parse_search_response(FIXTURE).unwrap();
185        let with_pdf: Vec<_> = papers.iter().filter(|p| p.pdf_url.is_some()).collect();
186        assert!(!with_pdf.is_empty(), "no papers with pdf_url");
187    }
188
189    #[test]
190    fn parse_open_access_always_true() {
191        let papers = parse_search_response(FIXTURE).unwrap();
192        for p in &papers {
193            assert_eq!(p.open_access, Some(true), "HAL papers are always OA");
194        }
195    }
196
197    #[test]
198    fn search_returns_papers() {
199        let mut server = mockito::Server::new();
200        let mock = server
201            .mock("GET", mockito::Matcher::Any)
202            .with_status(200)
203            .with_body(FIXTURE)
204            .create();
205        let papers = search(&server.url(), "test", 3).unwrap();
206        assert!(!papers.is_empty());
207        mock.assert();
208    }
209
210    #[test]
211    fn search_request_path() {
212        let mut server = mockito::Server::new();
213        let mock = server
214            .mock("GET", mockito::Matcher::Regex("/search/".to_string()))
215            .with_status(200)
216            .with_body(FIXTURE)
217            .create();
218        let _ = search(&server.url(), "test", 3);
219        mock.assert();
220    }
221
222    #[test]
223    fn search_request_contains_wt_json() {
224        let mut server = mockito::Server::new();
225        let mock = server
226            .mock("GET", mockito::Matcher::Regex("wt=json".to_string()))
227            .with_status(200)
228            .with_body(FIXTURE)
229            .create();
230        let _ = search(&server.url(), "test", 3);
231        mock.assert();
232    }
233
234    #[test]
235    fn search_request_contains_rows() {
236        let mut server = mockito::Server::new();
237        let mock = server
238            .mock("GET", mockito::Matcher::Regex("rows=3".to_string()))
239            .with_status(200)
240            .with_body(FIXTURE)
241            .create();
242        let _ = search(&server.url(), "test", 3);
243        mock.assert();
244    }
245
246    #[test]
247    fn search_request_contains_fl() {
248        let mut server = mockito::Server::new();
249        let mock = server
250            .mock("GET", mockito::Matcher::Regex("fl=".to_string()))
251            .with_status(200)
252            .with_body(FIXTURE)
253            .create();
254        let _ = search(&server.url(), "test", 3);
255        mock.assert();
256    }
257}