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 = std::env::var("DOIGET_CONTACT_EMAIL").unwrap_or_default();
222
223 let harness = FetchHarness::from_env().context("building fetch harness")?;
224 harness
225 .log_session_start(Some(query))
226 .context("logging session start")?;
227 let ctx = harness.fetch_context();
228
229 let outcome = paper_search(&base, &contact_email, &q, &ctx).await;
230 harness.log_session_end(outcome.is_ok(), Some(query));
231
232 let results = match outcome {
233 Ok(r) => r,
234 Err(e) => {
235 let code = ErrorCode::from(&e);
236 print_err(format_args!("error[{}]: {e}", code.as_wire()));
237 return Err(anyhow::Error::new(CliExit(cli_exit_code(code))));
238 }
239 };
240
241 if mode == OutputMode::Quiet && quiet_was_explicit {
244 return Ok(());
245 }
246
247 let stdout = std::io::stdout();
248 let mut out = stdout.lock();
249 if mode == OutputMode::Json {
250 write_json(&mut out, &external_envelope(query, &results))?;
251 return Ok(());
252 }
253
254 writeln!(out, "cited_by\tyear\toa\tdoi\ttitle")
257 .context("failed to write search header to stdout")?;
258 for hit in &results.results {
259 let year = dash_or(hit.year);
260 let oa = hit.oa_status.as_deref().unwrap_or("-");
261 let doi = hit.doi.as_deref().unwrap_or("-");
262 writeln!(
263 out,
264 "{}\t{}\t{}\t{}\t{}",
265 hit.cited_by_count, year, oa, doi, hit.title
266 )
267 .context("failed to write search row to stdout")?;
268 }
269 Ok(())
270}
271
272fn resolve_openalex_base() -> Result<url::Url> {
275 let raw =
276 std::env::var("DOIGET_OPENALEX_BASE").unwrap_or_else(|_| OPENALEX_DEFAULT_BASE.to_string());
277 url::Url::parse(&raw).with_context(|| format!("DOIGET_OPENALEX_BASE is not a URL: {raw}"))
278}
279
280fn local_envelope(query: &str, entries: &[EntryInfo]) -> serde_json::Value {
284 serde_json::json!({
285 "ok": true,
286 "scope": "local",
287 "query": query,
288 "count": entries.len(),
289 "results": entries,
290 })
291}
292
293fn external_envelope(query: &str, results: &PaperSearchResults) -> serde_json::Value {
297 serde_json::json!({
298 "ok": true,
299 "scope": "external",
300 "query": query,
301 "total_results": results.total_results,
302 "count": results.results.len(),
303 "results": results.results,
304 })
305}
306
307fn write_json(out: &mut impl Write, value: &serde_json::Value) -> Result<()> {
310 let s = serde_json::to_string_pretty(value).context("failed to serialize search JSON")?;
311 writeln!(out, "{s}").context("failed to write search JSON to stdout")
312}
313
314fn dash_or<T: std::fmt::Display>(v: Option<T>) -> String {
316 v.map(|x| x.to_string()).unwrap_or_else(|| "-".into())
317}
318
319#[cfg(test)]
320#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
321mod tests {
322 use super::*;
323 use doiget_core::discovery::{DiscoverySource, PaperHit};
324
325 fn hit() -> PaperHit {
326 PaperHit {
327 doi: Some("10.1234/x".to_string()),
328 openalex_id: "W1".to_string(),
329 arxiv: None,
330 title: "T".to_string(),
331 authors: vec!["A".to_string()],
332 year: Some(2024),
333 venue: Some("V".to_string()),
334 abstract_: Some("abs".to_string()),
335 cited_by_count: 3,
336 oa_status: Some("gold".to_string()),
337 fwci: Some(2.5),
338 cited_by_percentile_year_min: Some(85),
339 source: DiscoverySource::OpenAlex,
340 }
341 }
342
343 #[test]
344 fn external_envelope_has_scope_total_and_results() {
345 let results = PaperSearchResults {
346 results: vec![hit()],
347 total_results: Some(4012),
348 };
349 let v = external_envelope("spin glass", &results);
350 assert_eq!(v["ok"], true);
351 assert_eq!(v["scope"], "external");
352 assert_eq!(v["query"], "spin glass");
353 assert_eq!(v["total_results"], 4012);
354 assert_eq!(v["count"], 1);
355 assert_eq!(v["results"][0]["openalex_id"], "W1");
356 assert_eq!(v["results"][0]["abstract"], "abs");
357 }
358
359 #[test]
360 fn sort_arg_lowers_to_core() {
361 assert_eq!(SearchSort::from(SortArg::Relevance), SearchSort::Relevance);
363 }
364
365 #[test]
366 fn local_envelope_has_local_scope_and_count() {
367 let v = local_envelope("quantum", &[]);
368 assert_eq!(v["ok"], true);
369 assert_eq!(v["scope"], "local");
370 assert_eq!(v["query"], "quantum");
371 assert_eq!(v["count"], 0);
372 assert!(v["results"].as_array().expect("results array").is_empty());
373 assert!(v.get("total_results").is_none());
375 }
376
377 fn ext(limit: usize, from_year: Option<i32>, to_year: Option<i32>) -> ExternalArgs {
379 ExternalArgs {
380 limit,
381 from_year,
382 to_year,
383 oa_only: false,
384 min_citations: None,
385 min_fwci: None,
386 min_percentile: None,
387 author: None,
388 venue: None,
389 publisher: None,
390 sort: SortArg::Relevance,
391 }
392 }
393
394 #[tokio::test]
398 async fn external_rejects_limit_below_1() {
399 let err = run(
400 "q".into(),
401 false,
402 None,
403 ext(0, None, None),
404 OutputMode::Quiet,
405 true,
406 )
407 .await
408 .expect_err("limit 0 must be rejected");
409 assert!(err.to_string().contains("limit"), "got: {err}");
410 }
411
412 #[tokio::test]
413 async fn external_rejects_limit_above_200() {
414 let err = run(
415 "q".into(),
416 false,
417 None,
418 ext(201, None, None),
419 OutputMode::Quiet,
420 true,
421 )
422 .await
423 .expect_err("limit 201 must be rejected");
424 assert!(err.to_string().contains("limit"), "got: {err}");
425 }
426
427 #[tokio::test]
428 async fn external_rejects_inverted_year_range() {
429 let err = run(
430 "q".into(),
431 false,
432 None,
433 ext(25, Some(2025), Some(2010)),
434 OutputMode::Quiet,
435 true,
436 )
437 .await
438 .expect_err("from_year > to_year must be rejected");
439 assert!(err.to_string().contains("is after"), "got: {err}");
440 }
441}