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::print_err;
33use super::output::OutputMode;
34use super::resolve_store_root;
35
36const LOCAL_DEFAULT_LIMIT: usize = 50;
41
42const FETCHED_AT_FMT: &str = "%Y-%m-%dT%H:%M:%SZ";
46
47const OPENALEX_DEFAULT_BASE: &str = "https://api.openalex.org";
50
51#[derive(Clone, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
54pub enum SortArg {
55 #[default]
62 Relevance,
63}
64
65impl From<SortArg> for SearchSort {
66 fn from(s: SortArg) -> Self {
67 match s {
68 SortArg::Relevance => SearchSort::Relevance,
69 }
70 }
71}
72
73#[derive(Debug, Clone)]
76pub struct ExternalArgs {
77 pub limit: usize,
81 pub from_year: Option<i32>,
83 pub to_year: Option<i32>,
85 pub oa_only: bool,
87 pub min_citations: Option<u64>,
89 pub min_fwci: Option<f64>,
91 pub min_percentile: Option<u8>,
93 pub author: Option<String>,
95 pub venue: Option<String>,
97 pub publisher: Option<String>,
99 pub sort: SortArg,
101}
102
103pub async fn run(
117 query: String,
118 local: bool,
119 tag: Option<String>,
120 ext: ExternalArgs,
121 mode: OutputMode,
122 quiet_was_explicit: bool,
123) -> Result<()> {
124 let tag_filter = tag.as_deref();
125 if query.trim().is_empty() && !(local && tag_filter.is_some()) {
126 anyhow::bail!("search query is empty");
127 }
128 if local {
129 run_local(&query, tag_filter, mode, quiet_was_explicit)
130 } else {
131 run_external(&query, ext, mode, quiet_was_explicit).await
132 }
133}
134
135fn run_local(
139 query: &str,
140 tag_filter: Option<&str>,
141 mode: OutputMode,
142 quiet_was_explicit: bool,
143) -> Result<()> {
144 let store_root = resolve_store_root()?;
145 let store = FsStore::new(store_root)?;
146 let entries = if let Some(tag) = tag_filter {
147 store
148 .search_by_tag(tag, query, LOCAL_DEFAULT_LIMIT)
149 .with_context(|| format!("tag search failed for tag {tag:?}"))?
150 } else {
151 store
152 .search(query, LOCAL_DEFAULT_LIMIT)
153 .with_context(|| format!("search failed for query {query:?}"))?
154 };
155
156 if mode == OutputMode::Quiet && quiet_was_explicit {
159 return Ok(());
160 }
161
162 let stdout = std::io::stdout();
163 let mut out = stdout.lock();
164 if mode == OutputMode::Json {
165 write_json(&mut out, &local_envelope(query, &entries))?;
166 return Ok(());
167 }
168 writeln!(out, "safekey\tyear\ttitle\tfetched_at\tpdf")
171 .context("failed to write search header to stdout")?;
172 for e in &entries {
173 let year = dash_or(e.year);
174 let fetched = e
175 .fetched_at
176 .map(|t| t.format(FETCHED_AT_FMT).to_string())
177 .unwrap_or_else(|| "-".into());
178 writeln!(
179 out,
180 "{}\t{}\t{}\t{}\t{}",
181 e.safekey.as_str(),
182 year,
183 e.title,
184 fetched,
185 super::list_recent::pdf_cell(e)
186 )
187 .context("failed to write search row to stdout")?;
188 }
189 Ok(())
190}
191
192async fn run_external(
194 query: &str,
195 ext: ExternalArgs,
196 mode: OutputMode,
197 quiet_was_explicit: bool,
198) -> Result<()> {
199 let q = PaperSearchQuery {
200 query: query.to_string(),
201 limit: ext.limit,
202 from_year: ext.from_year,
203 to_year: ext.to_year,
204 oa_only: ext.oa_only,
205 min_citations: ext.min_citations,
206 min_fwci: ext.min_fwci,
207 min_percentile: ext.min_percentile,
208 author: ext.author,
209 venue: ext.venue,
210 publisher: ext.publisher,
211 sort: ext.sort.into(),
212 };
213 q.validate().map_err(|m| anyhow::anyhow!("{m}"))?;
216
217 let base = resolve_openalex_base()?;
218 let contact_email = doiget_core::orchestrator::configured_contact_email().unwrap_or_default();
223
224 let harness = FetchHarness::from_env().context("building fetch harness")?;
225 harness
226 .log_session_start(Some(query))
227 .context("logging session start")?;
228 let ctx = harness.fetch_context();
229
230 let outcome = paper_search(&base, &contact_email, &q, &ctx).await;
231 harness.log_session_end(
232 outcome.is_ok(),
233 Some(query),
234 outcome.as_ref().err().map(|e| ErrorCode::from(e).as_wire()),
235 );
236
237 let results = match outcome {
238 Ok(r) => r,
239 Err(e) => {
240 let code = ErrorCode::from(&e);
241 print_err(format_args!("error[{}]: {e}", code.as_wire()));
242 return Err(anyhow::Error::new(CliExit(cli_exit_code(code))));
243 }
244 };
245
246 if mode == OutputMode::Quiet && quiet_was_explicit {
249 return Ok(());
250 }
251
252 let stdout = std::io::stdout();
253 let mut out = stdout.lock();
254 if mode == OutputMode::Json {
255 write_json(&mut out, &external_envelope(query, &results))?;
256 return Ok(());
257 }
258
259 writeln!(out, "cited_by\tyear\toa\tdoi\ttitle")
262 .context("failed to write search header to stdout")?;
263 if results.results.is_empty() {
267 if let Some(hint) = doiget_core::discovery::zero_result_hint(query) {
268 print_err(format_args!(" = note: {hint}"));
269 }
270 }
271 for hit in &results.results {
272 let year = dash_or(hit.year);
273 let oa = hit.oa_status.as_deref().unwrap_or("-");
274 let doi = hit.doi.as_deref().unwrap_or("-");
275 writeln!(
276 out,
277 "{}\t{}\t{}\t{}\t{}",
278 hit.cited_by_count, year, oa, doi, hit.title
279 )
280 .context("failed to write search row to stdout")?;
281 }
282 Ok(())
283}
284
285fn resolve_openalex_base() -> Result<url::Url> {
288 let raw =
289 std::env::var("DOIGET_OPENALEX_BASE").unwrap_or_else(|_| OPENALEX_DEFAULT_BASE.to_string());
290 url::Url::parse(&raw).with_context(|| format!("DOIGET_OPENALEX_BASE is not a URL: {raw}"))
291}
292
293fn local_envelope(query: &str, entries: &[EntryInfo]) -> serde_json::Value {
297 serde_json::json!({
298 "ok": true,
299 "scope": "local",
300 "query": query,
301 "count": entries.len(),
302 "results": entries,
303 })
304}
305
306fn external_envelope(query: &str, results: &PaperSearchResults) -> serde_json::Value {
310 let mut envelope = serde_json::json!({
311 "ok": true,
312 "scope": "external",
313 "query": query,
314 "total_results": results.total_results,
315 "count": results.results.len(),
316 "results": results.results,
317 });
318 if results.results.is_empty() {
324 if let Some(hint) = doiget_core::discovery::zero_result_hint(query) {
325 envelope["hint"] = serde_json::json!(hint);
326 }
327 }
328 envelope
329}
330
331fn write_json(out: &mut impl Write, value: &serde_json::Value) -> Result<()> {
334 let s = serde_json::to_string_pretty(value).context("failed to serialize search JSON")?;
335 writeln!(out, "{s}").context("failed to write search JSON to stdout")
336}
337
338fn dash_or<T: std::fmt::Display>(v: Option<T>) -> String {
340 v.map(|x| x.to_string()).unwrap_or_else(|| "-".into())
341}
342
343#[cfg(test)]
344#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
345mod tests {
346 use super::*;
347 use doiget_core::discovery::{DiscoverySource, PaperHit};
348
349 fn hit() -> PaperHit {
350 PaperHit {
351 doi: Some("10.1234/x".to_string()),
352 openalex_id: "W1".to_string(),
353 arxiv: None,
354 title: "T".to_string(),
355 authors: vec!["A".to_string()],
356 year: Some(2024),
357 venue: Some("V".to_string()),
358 abstract_: Some("abs".to_string()),
359 cited_by_count: 3,
360 oa_status: Some("gold".to_string()),
361 fwci: Some(2.5),
362 cited_by_percentile_year_min: Some(85),
363 source: DiscoverySource::OpenAlex,
364 }
365 }
366
367 #[test]
368 fn external_envelope_has_scope_total_and_results() {
369 let results = PaperSearchResults {
370 results: vec![hit()],
371 total_results: Some(4012),
372 };
373 let v = external_envelope("spin glass", &results);
374 assert_eq!(v["ok"], true);
375 assert_eq!(v["scope"], "external");
376 assert_eq!(v["query"], "spin glass");
377 assert_eq!(v["total_results"], 4012);
378 assert_eq!(v["count"], 1);
379 assert_eq!(v["results"][0]["openalex_id"], "W1");
380 assert_eq!(v["results"][0]["abstract"], "abs");
381 }
382
383 #[test]
391 fn a_long_query_that_matched_nothing_carries_the_hint() {
392 let results = PaperSearchResults {
393 results: vec![],
394 total_results: Some(0),
395 };
396 let q = "lithium refractoriness after discontinuation kindling sensitization course of illness Post";
397 let v = external_envelope(q, &results);
398 assert_eq!(v["ok"], true, "still a success envelope: {v}");
399 assert_eq!(v["count"], 0);
400 let hint = v["hint"].as_str().unwrap_or_default();
401 assert!(hint.contains("10 terms"), "names the count: {hint:?}");
402 assert!(hint.contains("3-5"), "says what to do instead: {hint:?}");
403 }
404
405 #[test]
408 fn a_short_query_that_matched_nothing_is_left_alone() {
409 let results = PaperSearchResults {
410 results: vec![],
411 total_results: Some(0),
412 };
413 let v = external_envelope("depersonalization derealization", &results);
414 assert!(v.get("hint").is_none(), "no hint on a short query: {v}");
415 }
416
417 #[test]
419 fn a_long_query_with_results_carries_no_hint() {
420 let results = PaperSearchResults {
421 results: vec![hit()],
422 total_results: Some(1),
423 };
424 let q = "a b c d e f g h i j k l";
425 let v = external_envelope(q, &results);
426 assert!(v.get("hint").is_none(), "results present: {v}");
427 }
428
429 #[test]
430 fn sort_arg_lowers_to_core() {
431 assert_eq!(SearchSort::from(SortArg::Relevance), SearchSort::Relevance);
433 }
434
435 #[test]
436 fn local_envelope_has_local_scope_and_count() {
437 let v = local_envelope("quantum", &[]);
438 assert_eq!(v["ok"], true);
439 assert_eq!(v["scope"], "local");
440 assert_eq!(v["query"], "quantum");
441 assert_eq!(v["count"], 0);
442 assert!(v["results"].as_array().expect("results array").is_empty());
443 assert!(v.get("total_results").is_none());
445 }
446
447 fn ext(limit: usize, from_year: Option<i32>, to_year: Option<i32>) -> ExternalArgs {
449 ExternalArgs {
450 limit,
451 from_year,
452 to_year,
453 oa_only: false,
454 min_citations: None,
455 min_fwci: None,
456 min_percentile: None,
457 author: None,
458 venue: None,
459 publisher: None,
460 sort: SortArg::Relevance,
461 }
462 }
463
464 #[tokio::test]
468 async fn external_rejects_limit_below_1() {
469 let err = run(
470 "q".into(),
471 false,
472 None,
473 ext(0, None, None),
474 OutputMode::Quiet,
475 true,
476 )
477 .await
478 .expect_err("limit 0 must be rejected");
479 assert!(err.to_string().contains("limit"), "got: {err}");
480 }
481
482 #[tokio::test]
483 async fn external_rejects_limit_above_200() {
484 let err = run(
485 "q".into(),
486 false,
487 None,
488 ext(201, None, None),
489 OutputMode::Quiet,
490 true,
491 )
492 .await
493 .expect_err("limit 201 must be rejected");
494 assert!(err.to_string().contains("limit"), "got: {err}");
495 }
496
497 #[tokio::test]
498 async fn external_rejects_inverted_year_range() {
499 let err = run(
500 "q".into(),
501 false,
502 None,
503 ext(25, Some(2025), Some(2010)),
504 OutputMode::Quiet,
505 true,
506 )
507 .await
508 .expect_err("from_year > to_year must be rejected");
509 assert!(err.to_string().contains("is after"), "got: {err}");
510 }
511}