1use std::io::Write;
24
25use anyhow::{Context, Result};
26
27use doiget_core::discovery::{paper_search, PaperSearchQuery, PaperSearchResults, SearchSort};
28use doiget_core::store::{EntryInfo, FsStore, Store};
29use doiget_core::ErrorCode;
30
31use super::fetch::{cli_exit_code, CliExit, FetchHarness};
32use super::output::OutputMode;
33use super::resolve_store_root;
34
35const LOCAL_DEFAULT_LIMIT: usize = 50;
40
41const FETCHED_AT_FMT: &str = "%Y-%m-%dT%H:%M:%SZ";
45
46const OPENALEX_DEFAULT_BASE: &str = "https://api.openalex.org";
49
50#[derive(Clone, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
53pub enum SortArg {
54 #[default]
61 Relevance,
62}
63
64impl From<SortArg> for SearchSort {
65 fn from(s: SortArg) -> Self {
66 match s {
67 SortArg::Relevance => SearchSort::Relevance,
68 }
69 }
70}
71
72#[derive(Debug, Clone)]
75pub struct ExternalArgs {
76 pub limit: usize,
80 pub from_year: Option<i32>,
82 pub to_year: Option<i32>,
84 pub oa_only: bool,
86 pub min_citations: Option<u64>,
88 pub min_fwci: Option<f64>,
90 pub min_percentile: Option<u8>,
92 pub author: Option<String>,
94 pub venue: Option<String>,
96 pub publisher: Option<String>,
98 pub sort: SortArg,
100}
101
102#[allow(clippy::print_stderr)]
105fn print_err(args: std::fmt::Arguments<'_>) {
106 eprintln!("{args}");
107}
108
109pub async fn run(
123 query: String,
124 local: bool,
125 tag: Option<String>,
126 ext: ExternalArgs,
127 mode: OutputMode,
128 quiet_was_explicit: bool,
129) -> Result<()> {
130 let tag_filter = tag.as_deref();
131 if query.trim().is_empty() && !(local && tag_filter.is_some()) {
132 anyhow::bail!("search query is empty");
133 }
134 if local {
135 run_local(&query, tag_filter, mode, quiet_was_explicit)
136 } else {
137 run_external(&query, ext, mode, quiet_was_explicit).await
138 }
139}
140
141fn run_local(
145 query: &str,
146 tag_filter: Option<&str>,
147 mode: OutputMode,
148 quiet_was_explicit: bool,
149) -> Result<()> {
150 let store_root = resolve_store_root()?;
151 let store = FsStore::new(store_root)?;
152 let entries = if let Some(tag) = tag_filter {
153 store
154 .search_by_tag(tag, query, LOCAL_DEFAULT_LIMIT)
155 .with_context(|| format!("tag search failed for tag {tag:?}"))?
156 } else {
157 store
158 .search(query, LOCAL_DEFAULT_LIMIT)
159 .with_context(|| format!("search failed for query {query:?}"))?
160 };
161
162 if mode == OutputMode::Quiet && quiet_was_explicit {
165 return Ok(());
166 }
167
168 let stdout = std::io::stdout();
169 let mut out = stdout.lock();
170 if mode == OutputMode::Json {
171 write_json(&mut out, &local_envelope(query, &entries))?;
172 return Ok(());
173 }
174 writeln!(out, "safekey\tyear\ttitle\tfetched_at")
175 .context("failed to write search header to stdout")?;
176 for e in entries {
177 let year = dash_or(e.year);
178 let fetched = e
179 .fetched_at
180 .map(|t| t.format(FETCHED_AT_FMT).to_string())
181 .unwrap_or_else(|| "-".into());
182 writeln!(
183 out,
184 "{}\t{}\t{}\t{}",
185 e.safekey.as_str(),
186 year,
187 e.title,
188 fetched
189 )
190 .context("failed to write search row to stdout")?;
191 }
192 Ok(())
193}
194
195async fn run_external(
197 query: &str,
198 ext: ExternalArgs,
199 mode: OutputMode,
200 quiet_was_explicit: bool,
201) -> Result<()> {
202 let q = PaperSearchQuery {
203 query: query.to_string(),
204 limit: ext.limit,
205 from_year: ext.from_year,
206 to_year: ext.to_year,
207 oa_only: ext.oa_only,
208 min_citations: ext.min_citations,
209 min_fwci: ext.min_fwci,
210 min_percentile: ext.min_percentile,
211 author: ext.author,
212 venue: ext.venue,
213 publisher: ext.publisher,
214 sort: ext.sort.into(),
215 };
216 q.validate().map_err(|m| anyhow::anyhow!("{m}"))?;
219
220 let base = resolve_openalex_base()?;
221 let contact_email = std::env::var("DOIGET_CONTACT_EMAIL").unwrap_or_default();
225
226 let harness = FetchHarness::from_env().context("building fetch harness")?;
227 harness
228 .log_session_start(Some(query))
229 .context("logging session start")?;
230 let ctx = harness.fetch_context();
231
232 let outcome = paper_search(&base, &contact_email, &q, &ctx).await;
233 harness.log_session_end(outcome.is_ok(), Some(query));
234
235 let results = match outcome {
236 Ok(r) => r,
237 Err(e) => {
238 let code = ErrorCode::from(&e);
239 print_err(format_args!("error[{}]: {e}", code.as_wire()));
240 return Err(anyhow::Error::new(CliExit(cli_exit_code(code))));
241 }
242 };
243
244 if mode == OutputMode::Quiet && quiet_was_explicit {
247 return Ok(());
248 }
249
250 let stdout = std::io::stdout();
251 let mut out = stdout.lock();
252 if mode == OutputMode::Json {
253 write_json(&mut out, &external_envelope(query, &results))?;
254 return Ok(());
255 }
256
257 writeln!(out, "cited_by\tyear\toa\tdoi\ttitle")
260 .context("failed to write search header to stdout")?;
261 for hit in &results.results {
262 let year = dash_or(hit.year);
263 let oa = hit.oa_status.as_deref().unwrap_or("-");
264 let doi = hit.doi.as_deref().unwrap_or("-");
265 writeln!(
266 out,
267 "{}\t{}\t{}\t{}\t{}",
268 hit.cited_by_count, year, oa, doi, hit.title
269 )
270 .context("failed to write search row to stdout")?;
271 }
272 Ok(())
273}
274
275fn resolve_openalex_base() -> Result<url::Url> {
278 let raw =
279 std::env::var("DOIGET_OPENALEX_BASE").unwrap_or_else(|_| OPENALEX_DEFAULT_BASE.to_string());
280 url::Url::parse(&raw).with_context(|| format!("DOIGET_OPENALEX_BASE is not a URL: {raw}"))
281}
282
283fn local_envelope(query: &str, entries: &[EntryInfo]) -> serde_json::Value {
287 serde_json::json!({
288 "ok": true,
289 "scope": "local",
290 "query": query,
291 "count": entries.len(),
292 "results": entries,
293 })
294}
295
296fn external_envelope(query: &str, results: &PaperSearchResults) -> serde_json::Value {
300 serde_json::json!({
301 "ok": true,
302 "scope": "external",
303 "query": query,
304 "total_results": results.total_results,
305 "count": results.results.len(),
306 "results": results.results,
307 })
308}
309
310fn write_json(out: &mut impl Write, value: &serde_json::Value) -> Result<()> {
313 let s = serde_json::to_string_pretty(value).context("failed to serialize search JSON")?;
314 writeln!(out, "{s}").context("failed to write search JSON to stdout")
315}
316
317fn dash_or<T: std::fmt::Display>(v: Option<T>) -> String {
319 v.map(|x| x.to_string()).unwrap_or_else(|| "-".into())
320}
321
322#[cfg(test)]
323#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
324mod tests {
325 use super::*;
326 use doiget_core::discovery::{DiscoverySource, PaperHit};
327
328 fn hit() -> PaperHit {
329 PaperHit {
330 doi: Some("10.1234/x".to_string()),
331 openalex_id: "W1".to_string(),
332 arxiv: None,
333 title: "T".to_string(),
334 authors: vec!["A".to_string()],
335 year: Some(2024),
336 venue: Some("V".to_string()),
337 abstract_: Some("abs".to_string()),
338 cited_by_count: 3,
339 oa_status: Some("gold".to_string()),
340 fwci: Some(2.5),
341 cited_by_percentile_year_min: Some(85),
342 source: DiscoverySource::OpenAlex,
343 }
344 }
345
346 #[test]
347 fn external_envelope_has_scope_total_and_results() {
348 let results = PaperSearchResults {
349 results: vec![hit()],
350 total_results: Some(4012),
351 };
352 let v = external_envelope("spin glass", &results);
353 assert_eq!(v["ok"], true);
354 assert_eq!(v["scope"], "external");
355 assert_eq!(v["query"], "spin glass");
356 assert_eq!(v["total_results"], 4012);
357 assert_eq!(v["count"], 1);
358 assert_eq!(v["results"][0]["openalex_id"], "W1");
359 assert_eq!(v["results"][0]["abstract"], "abs");
360 }
361
362 #[test]
363 fn sort_arg_lowers_to_core() {
364 assert_eq!(SearchSort::from(SortArg::Relevance), SearchSort::Relevance);
366 }
367
368 #[test]
369 fn local_envelope_has_local_scope_and_count() {
370 let v = local_envelope("quantum", &[]);
371 assert_eq!(v["ok"], true);
372 assert_eq!(v["scope"], "local");
373 assert_eq!(v["query"], "quantum");
374 assert_eq!(v["count"], 0);
375 assert!(v["results"].as_array().expect("results array").is_empty());
376 assert!(v.get("total_results").is_none());
378 }
379
380 fn ext(limit: usize, from_year: Option<i32>, to_year: Option<i32>) -> ExternalArgs {
382 ExternalArgs {
383 limit,
384 from_year,
385 to_year,
386 oa_only: false,
387 min_citations: None,
388 min_fwci: None,
389 min_percentile: None,
390 author: None,
391 venue: None,
392 publisher: None,
393 sort: SortArg::Relevance,
394 }
395 }
396
397 #[tokio::test]
401 async fn external_rejects_limit_below_1() {
402 let err = run(
403 "q".into(),
404 false,
405 None,
406 ext(0, None, None),
407 OutputMode::Quiet,
408 true,
409 )
410 .await
411 .expect_err("limit 0 must be rejected");
412 assert!(err.to_string().contains("limit"), "got: {err}");
413 }
414
415 #[tokio::test]
416 async fn external_rejects_limit_above_200() {
417 let err = run(
418 "q".into(),
419 false,
420 None,
421 ext(201, None, None),
422 OutputMode::Quiet,
423 true,
424 )
425 .await
426 .expect_err("limit 201 must be rejected");
427 assert!(err.to_string().contains("limit"), "got: {err}");
428 }
429
430 #[tokio::test]
431 async fn external_rejects_inverted_year_range() {
432 let err = run(
433 "q".into(),
434 false,
435 None,
436 ext(25, Some(2025), Some(2010)),
437 OutputMode::Quiet,
438 true,
439 )
440 .await
441 .expect_err("from_year > to_year must be rejected");
442 assert!(err.to_string().contains("is after"), "got: {err}");
443 }
444}