1use super::Paper;
2
3const FIELDS: &str = "halId_s,title_s,authFullName_s,abstract_s,doiId_s,publicationDateY_i,fileMain_s,uri_s";
4
5pub 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
41pub 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 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 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}