1use serde::Serialize;
54use url::Url;
55
56use crate::provenance::{Capability, LogEvent, LogResult, RowInput};
57use crate::source::{FetchContext, FetchError};
58
59const SOURCE_KEY: &str = "openalex";
68
69const SELECT_FIELDS: &str = "id,doi,title,display_name,publication_year,\
73cited_by_count,fwci,cited_by_percentile_year,abstract_inverted_index,authorships,\
74primary_location,open_access,locations";
75
76pub const MAX_PER_PAGE: usize = 200;
81
82pub const DEFAULT_LIMIT: usize = 25;
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
97pub enum SearchSort {
98 #[default]
101 Relevance,
102}
103
104impl SearchSort {
105 #[must_use]
107 pub fn as_openalex(self) -> &'static str {
108 match self {
109 SearchSort::Relevance => "relevance_score:desc",
110 }
111 }
112}
113
114#[derive(Debug, Clone)]
119pub struct PaperSearchQuery {
120 pub query: String,
124 pub limit: usize,
129 pub from_year: Option<i32>,
132 pub to_year: Option<i32>,
135 pub oa_only: bool,
137 pub min_citations: Option<u64>,
141 pub min_fwci: Option<f64>,
145 pub min_percentile: Option<u8>,
150 pub author: Option<String>,
153 pub venue: Option<String>,
157 pub publisher: Option<String>,
161 pub sort: SearchSort,
163}
164
165impl PaperSearchQuery {
166 #[must_use]
168 pub fn new(query: impl Into<String>) -> Self {
169 Self {
170 query: query.into(),
171 limit: DEFAULT_LIMIT,
172 from_year: None,
173 to_year: None,
174 oa_only: false,
175 min_citations: None,
176 min_fwci: None,
177 min_percentile: None,
178 author: None,
179 venue: None,
180 publisher: None,
181 sort: SearchSort::Relevance,
182 }
183 }
184
185 pub fn validate(&self) -> Result<(), String> {
200 if self.query.trim().is_empty() {
201 return Err("search query is empty".to_string());
202 }
203 if !(1..=MAX_PER_PAGE).contains(&self.limit) {
204 return Err(format!(
205 "limit must be between 1 and {MAX_PER_PAGE} (got {})",
206 self.limit
207 ));
208 }
209 if let (Some(from), Some(to)) = (self.from_year, self.to_year) {
210 if from > to {
211 return Err(format!("from_year ({from}) is after to_year ({to})"));
212 }
213 }
214 if let Some(f) = self.min_fwci {
219 if !f.is_finite() || f < 0.0 {
220 return Err(format!(
221 "min_fwci must be a finite, non-negative number (got {f})"
222 ));
223 }
224 }
225 if let Some(p) = self.min_percentile {
229 if p > 100 {
230 return Err(format!(
231 "min_percentile must be between 0 and 100 (got {p})"
232 ));
233 }
234 }
235 Ok(())
236 }
237}
238
239#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
246#[serde(rename_all = "lowercase")]
247#[non_exhaustive]
248pub enum DiscoverySource {
249 OpenAlex,
251}
252
253#[derive(Debug, Clone, Serialize, PartialEq)]
261pub struct PaperHit {
262 pub doi: Option<String>,
265 pub openalex_id: String,
270 pub arxiv: Option<String>,
273 pub title: String,
275 pub authors: Vec<String>,
277 pub year: Option<i32>,
279 pub venue: Option<String>,
281 #[serde(rename = "abstract")]
284 pub abstract_: Option<String>,
285 pub cited_by_count: u64,
287 pub oa_status: Option<String>,
290 pub fwci: Option<f64>,
294 pub cited_by_percentile_year_min: Option<u8>,
299 pub source: DiscoverySource,
302}
303
304#[derive(Debug, Clone, Serialize, PartialEq)]
306pub struct PaperSearchResults {
307 pub results: Vec<PaperHit>,
309 pub total_results: Option<u64>,
313}
314
315#[derive(Debug, Default)]
318struct ResolvedIds {
319 author: Option<String>,
321 source: Option<String>,
323 publisher: Option<String>,
325}
326
327pub async fn paper_search(
361 base: &Url,
362 contact_email: &str,
363 query: &PaperSearchQuery,
364 ctx: &FetchContext,
365) -> Result<PaperSearchResults, FetchError> {
366 let ids = ResolvedIds {
368 author: resolve_optional(base, contact_email, "authors", &query.author, ctx).await?,
369 source: resolve_optional(base, contact_email, "sources", &query.venue, ctx).await?,
370 publisher: resolve_optional(base, contact_email, "publishers", &query.publisher, ctx)
371 .await?,
372 };
373
374 let url = build_search_url(base, contact_email, query, &ids)?;
375 let (value, _bytes) = openalex_get(&url, ctx).await?;
376
377 let results_array = value
378 .get("results")
379 .and_then(serde_json::Value::as_array)
380 .ok_or_else(|| missing_results_array("search", &value))?;
381
382 let results: Vec<PaperHit> = results_array.iter().map(work_to_hit).collect();
383 let total_results = value
384 .get("meta")
385 .and_then(|m| m.get("count"))
386 .and_then(serde_json::Value::as_u64);
387
388 Ok(PaperSearchResults {
389 results,
390 total_results,
391 })
392}
393
394async fn openalex_get(
399 url: &Url,
400 ctx: &FetchContext,
401) -> Result<(serde_json::Value, usize), FetchError> {
402 let _permit = ctx.rate_limiter.acquire(SOURCE_KEY).await;
404
405 let (body, _final_url) = ctx.http.fetch_bytes(SOURCE_KEY, url.clone()).await?;
407
408 let value: serde_json::Value =
410 serde_json::from_slice(&body).map_err(|e| FetchError::SourceSchema {
411 hint: format!("openalex returned non-JSON: {e}"),
412 })?;
413
414 ctx.log.append(RowInput {
418 event: LogEvent::Fetch,
419 result: LogResult::Ok,
420 capability: Capability::Metadata,
421 ref_: None,
422 source: Some(SOURCE_KEY),
423 error_code: None,
424 size_bytes: Some(body.len() as u64),
425 license: None,
426 store_path: None,
427 canonical_digest: None,
428 })?;
429
430 Ok((value, body.len()))
431}
432
433async fn resolve_optional(
436 base: &Url,
437 contact_email: &str,
438 entity_path: &str,
439 name: &Option<String>,
440 ctx: &FetchContext,
441) -> Result<Option<String>, FetchError> {
442 match name {
443 Some(n) if !n.trim().is_empty() => Ok(Some(
444 resolve_entity_id(base, contact_email, entity_path, n, ctx).await?,
445 )),
446 _ => Ok(None),
447 }
448}
449
450async fn resolve_entity_id(
462 base: &Url,
463 contact_email: &str,
464 entity_path: &str,
465 name: &str,
466 ctx: &FetchContext,
467) -> Result<String, FetchError> {
468 let mut url = base
469 .join(&format!("/{entity_path}"))
470 .map_err(|e| FetchError::SourceSchema {
471 hint: format!("openalex {entity_path} URL construction failed: {e}"),
472 })?;
473 {
474 let mut qp = url.query_pairs_mut();
475 qp.append_pair("search", name);
476 qp.append_pair("per-page", "5");
481 if !contact_email.is_empty() {
482 qp.append_pair("mailto", contact_email);
483 }
484 }
485
486 let (value, _len) = openalex_get(&url, ctx).await?;
487 let results_arr = value
493 .get("results")
494 .and_then(serde_json::Value::as_array)
495 .ok_or_else(|| missing_results_array(&format!("/{entity_path}"), &value))?;
496 let mut candidates: Vec<Candidate> = results_arr
497 .iter()
498 .filter_map(Candidate::from_value)
499 .collect();
500 candidates.sort_by(|a, b| {
503 b.relevance
504 .partial_cmp(&a.relevance)
505 .unwrap_or(std::cmp::Ordering::Equal)
506 });
507
508 select_entity(entity_path, name, &candidates)
509}
510
511struct Candidate {
513 id: String,
515 display_name: String,
517 works_count: u64,
520 relevance: f64,
522}
523
524impl Candidate {
525 fn from_value(v: &serde_json::Value) -> Option<Self> {
526 let id = v
527 .get("id")
528 .and_then(serde_json::Value::as_str)
529 .map(strip_openalex_prefix)?;
530 Some(Self {
531 id,
532 display_name: v
533 .get("display_name")
534 .and_then(serde_json::Value::as_str)
535 .unwrap_or("")
536 .to_string(),
537 works_count: v
538 .get("works_count")
539 .and_then(serde_json::Value::as_u64)
540 .unwrap_or(0),
541 relevance: v
542 .get("relevance_score")
543 .and_then(serde_json::Value::as_f64)
544 .unwrap_or(0.0),
545 })
546 }
547}
548
549const DOMINANCE_RATIO: f64 = 2.0;
553
554fn select_entity(
562 entity_path: &str,
563 name: &str,
564 candidates: &[Candidate],
565) -> Result<String, FetchError> {
566 let label = entity_label(entity_path);
567 if candidates.is_empty() {
568 return Err(FetchError::NotFound {
569 hint: format!("no OpenAlex {label} matched '{name}'"),
570 });
571 }
572 if candidates.len() == 1 {
573 return Ok(candidates[0].id.clone());
574 }
575
576 let exact: Vec<&Candidate> = candidates
577 .iter()
578 .filter(|c| c.display_name.trim().eq_ignore_ascii_case(name.trim()))
579 .collect();
580 if exact.len() == 1 {
581 return Ok(exact[0].id.clone());
582 }
583
584 if exact.is_empty() {
585 let top = &candidates[0];
586 let second = &candidates[1];
587 if top.relevance > 0.0
593 && second.relevance > 0.0
594 && top.relevance >= DOMINANCE_RATIO * second.relevance
595 {
596 return Ok(top.id.clone());
597 }
598 }
599
600 Err(FetchError::Ambiguous {
601 hint: format_ambiguous(label, name, candidates),
602 })
603}
604
605fn entity_label(entity_path: &str) -> &str {
607 match entity_path {
608 "authors" => "author",
609 "sources" => "venue",
610 "publishers" => "publisher",
611 other => other,
612 }
613}
614
615fn format_ambiguous(label: &str, name: &str, candidates: &[Candidate]) -> String {
618 let mut s = format!(
619 "ambiguous {label} '{name}' — {} candidates; narrow the name \
620 (add a first name / fuller title) and retry:",
621 candidates.len()
622 );
623 for c in candidates.iter().take(5) {
624 s.push_str(&format!(
625 "\n {} ({}, {} works)",
626 c.display_name, c.id, c.works_count
627 ));
628 }
629 s
630}
631
632fn build_search_url(
634 base: &Url,
635 contact_email: &str,
636 query: &PaperSearchQuery,
637 ids: &ResolvedIds,
638) -> Result<Url, FetchError> {
639 let mut url = base.join("/works").map_err(|e| FetchError::SourceSchema {
640 hint: format!("openalex search URL construction failed: {e}"),
641 })?;
642
643 let per_page = query.limit.clamp(1, MAX_PER_PAGE);
644
645 let mut filters: Vec<String> = Vec::new();
648 filters.push(format!(
655 "title_and_abstract.search:{}",
656 query.query.replace(',', " ")
657 ));
658 if let Some(from) = query.from_year {
659 filters.push(format!("from_publication_date:{from}-01-01"));
660 }
661 if let Some(to) = query.to_year {
662 filters.push(format!("to_publication_date:{to}-12-31"));
663 }
664 if query.oa_only {
665 filters.push("is_oa:true".to_string());
666 }
667 if let Some(min) = query.min_citations {
668 filters.push(format!("cited_by_count:>{min}"));
672 }
673 if let Some(f) = query.min_fwci {
674 filters.push(format!("fwci:>{f}"));
677 }
678 if let Some(p) = query.min_percentile {
679 filters.push(format!("cited_by_percentile_year.min:{p}"));
681 }
682 if let Some(author_id) = &ids.author {
683 filters.push(format!("authorships.author.id:{author_id}"));
684 }
685 if let Some(source_id) = &ids.source {
686 filters.push(format!("primary_location.source.id:{source_id}"));
687 }
688 if let Some(publisher_id) = &ids.publisher {
689 filters.push(format!(
690 "primary_location.source.publisher_lineage:{publisher_id}"
691 ));
692 }
693
694 {
695 let mut qp = url.query_pairs_mut();
696 qp.append_pair("per-page", &per_page.to_string());
699 qp.append_pair("sort", query.sort.as_openalex());
700 qp.append_pair("select", SELECT_FIELDS);
701 qp.append_pair("filter", &filters.join(","));
704 if !contact_email.is_empty() {
705 qp.append_pair("mailto", contact_email);
706 }
707 }
708
709 Ok(url)
710}
711
712fn work_to_hit(work: &serde_json::Value) -> PaperHit {
718 let openalex_id = work
719 .get("id")
720 .and_then(serde_json::Value::as_str)
721 .map(strip_openalex_prefix)
722 .unwrap_or_default();
723
724 let doi = work
725 .get("doi")
726 .and_then(serde_json::Value::as_str)
727 .map(strip_doi_prefix);
728
729 let title = work
730 .get("title")
731 .and_then(serde_json::Value::as_str)
732 .or_else(|| work.get("display_name").and_then(serde_json::Value::as_str))
733 .unwrap_or("")
734 .to_string();
735
736 let authors = work
737 .get("authorships")
738 .and_then(serde_json::Value::as_array)
739 .map(|arr| {
740 arr.iter()
741 .filter_map(|a| {
742 a.get("author")
743 .and_then(|au| au.get("display_name"))
744 .and_then(serde_json::Value::as_str)
745 .map(str::to_string)
746 })
747 .collect()
748 })
749 .unwrap_or_default();
750
751 let year = work
752 .get("publication_year")
753 .and_then(serde_json::Value::as_i64)
754 .and_then(|y| i32::try_from(y).ok());
755
756 let venue = work
757 .get("primary_location")
758 .and_then(|loc| loc.get("source"))
759 .and_then(|src| src.get("display_name"))
760 .and_then(serde_json::Value::as_str)
761 .map(str::to_string);
762
763 let abstract_ = work
764 .get("abstract_inverted_index")
765 .and_then(reconstruct_abstract);
766
767 let cited_by_count = work
768 .get("cited_by_count")
769 .and_then(serde_json::Value::as_u64)
770 .unwrap_or(0);
771
772 let oa_status = work
773 .get("open_access")
774 .and_then(|oa| oa.get("oa_status"))
775 .and_then(serde_json::Value::as_str)
776 .map(str::to_string);
777
778 let arxiv = work
779 .get("locations")
780 .and_then(serde_json::Value::as_array)
781 .and_then(|locs| locs.iter().find_map(extract_arxiv_from_location));
782
783 let fwci = work.get("fwci").and_then(serde_json::Value::as_f64);
784
785 let cited_by_percentile_year_min = work
786 .get("cited_by_percentile_year")
787 .and_then(|p| p.get("min"))
788 .and_then(serde_json::Value::as_u64)
789 .and_then(|v| u8::try_from(v).ok());
790
791 PaperHit {
792 doi,
793 openalex_id,
794 arxiv,
795 title,
796 authors,
797 year,
798 venue,
799 abstract_,
800 cited_by_count,
801 oa_status,
802 fwci,
803 cited_by_percentile_year_min,
804 source: DiscoverySource::OpenAlex,
805 }
806}
807
808fn reconstruct_abstract(inv: &serde_json::Value) -> Option<String> {
812 let map = inv.as_object()?;
813 if map.is_empty() {
814 return None;
815 }
816 let mut positioned: Vec<(u64, &str)> = Vec::new();
817 for (word, positions) in map {
818 if let Some(arr) = positions.as_array() {
819 for p in arr {
820 if let Some(pos) = p.as_u64() {
821 positioned.push((pos, word.as_str()));
822 }
823 }
824 }
825 }
826 if positioned.is_empty() {
827 return None;
828 }
829 positioned.sort_by_key(|(pos, _)| *pos);
830 let words: Vec<&str> = positioned.into_iter().map(|(_, w)| w).collect();
831 Some(words.join(" "))
832}
833
834fn extract_arxiv_from_location(loc: &serde_json::Value) -> Option<String> {
842 for key in ["landing_page_url", "pdf_url"] {
843 if let Some(u) = loc.get(key).and_then(serde_json::Value::as_str) {
844 if let Some(idx) = u.find("arxiv.org/abs/") {
845 let after = &u[idx + "arxiv.org/abs/".len()..];
846 let raw: String = after
849 .chars()
850 .take_while(|c| !matches!(c, '?' | '#' | ' ' | '\t' | '\n' | '\r'))
851 .collect();
852 if let Ok(id) = crate::ArxivId::parse(raw.trim_end_matches('/')) {
853 return Some(id.as_str().to_string());
854 }
855 }
856 }
857 }
858 None
859}
860
861fn strip_openalex_prefix(id: &str) -> String {
864 id.rsplit('/').next().unwrap_or(id).to_string()
865}
866
867fn strip_doi_prefix(doi_url: &str) -> String {
871 let lower = doi_url.to_ascii_lowercase();
872 lower
873 .strip_prefix("https://doi.org/")
874 .or_else(|| lower.strip_prefix("http://doi.org/"))
875 .unwrap_or(&lower)
876 .to_string()
877}
878
879fn truncate_for_hint(body: &[u8]) -> String {
886 const MAX: usize = 200;
887 let s = String::from_utf8_lossy(body);
888 if s.chars().count() <= MAX {
889 s.into_owned()
890 } else {
891 let head: String = s.chars().take(MAX).collect();
892 format!("{head}…")
893 }
894}
895
896fn missing_results_array(context: &str, value: &serde_json::Value) -> FetchError {
902 FetchError::SourceSchema {
903 hint: format!(
904 "openalex {context} response missing `results` array — likely an \
905 error payload (got: {})",
906 truncate_for_hint(value.to_string().as_bytes())
907 ),
908 }
909}
910
911#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
924pub struct PaperLinks {
925 pub doi: Option<String>,
927 pub arxiv: Option<String>,
930 pub openalex_id: String,
932 pub title: String,
934}
935
936pub async fn resolve_links_for_doi(
955 base: &Url,
956 contact_email: &str,
957 doi: &str,
958 ctx: &FetchContext,
959) -> Result<PaperLinks, FetchError> {
960 let url = build_doi_lookup_url(base, contact_email, doi)?;
961 let (value, _bytes) = openalex_get(&url, ctx).await?;
962
963 let results = value
964 .get("results")
965 .and_then(serde_json::Value::as_array)
966 .ok_or_else(|| missing_results_array("doi-lookup", &value))?;
967
968 let work = results.first().ok_or_else(|| FetchError::NotFound {
969 hint: format!("no OpenAlex work matched doi '{doi}'"),
970 })?;
971
972 let links = work_to_links(work);
973 if links.openalex_id.is_empty() {
977 return Err(FetchError::SourceSchema {
978 hint: format!("openalex work for doi '{doi}' has no id"),
979 });
980 }
981 Ok(links)
982}
983
984fn build_doi_lookup_url(base: &Url, contact_email: &str, doi: &str) -> Result<Url, FetchError> {
990 let mut url = base.join("/works").map_err(|e| FetchError::SourceSchema {
991 hint: format!("openalex doi-lookup URL construction failed: {e}"),
992 })?;
993 {
994 let mut qp = url.query_pairs_mut();
995 qp.append_pair("filter", &format!("doi:{doi}"));
996 qp.append_pair("per-page", "1");
997 qp.append_pair(
998 "select",
999 "id,doi,title,display_name,locations,primary_location,best_oa_location",
1000 );
1001 if !contact_email.is_empty() {
1002 qp.append_pair("mailto", contact_email);
1003 }
1004 }
1005 Ok(url)
1006}
1007
1008fn work_to_links(work: &serde_json::Value) -> PaperLinks {
1012 let openalex_id = work
1013 .get("id")
1014 .and_then(serde_json::Value::as_str)
1015 .map(strip_openalex_prefix)
1016 .unwrap_or_default();
1017
1018 let doi = work
1019 .get("doi")
1020 .and_then(serde_json::Value::as_str)
1021 .map(strip_doi_prefix);
1022
1023 let title = work
1024 .get("title")
1025 .and_then(serde_json::Value::as_str)
1026 .or_else(|| work.get("display_name").and_then(serde_json::Value::as_str))
1027 .unwrap_or("")
1028 .to_string();
1029
1030 let arxiv = work
1031 .get("locations")
1032 .and_then(serde_json::Value::as_array)
1033 .and_then(|locs| locs.iter().find_map(extract_arxiv_from_location))
1034 .or_else(|| {
1035 work.get("primary_location")
1036 .and_then(extract_arxiv_from_location)
1037 })
1038 .or_else(|| {
1039 work.get("best_oa_location")
1040 .and_then(extract_arxiv_from_location)
1041 });
1042
1043 PaperLinks {
1044 doi,
1045 arxiv,
1046 openalex_id,
1047 title,
1048 }
1049}
1050
1051#[derive(Debug, Clone)]
1062pub struct FrontierQuery {
1063 pub seed_doi: crate::Doi,
1065 pub limit: usize,
1068 pub min_year: Option<i32>,
1070}
1071
1072impl FrontierQuery {
1073 pub fn new(seed_doi: crate::Doi) -> Self {
1075 Self {
1076 seed_doi,
1077 limit: DEFAULT_LIMIT,
1078 min_year: None,
1079 }
1080 }
1081}
1082
1083#[derive(Debug, Clone, Serialize)]
1085pub struct FrontierResults {
1086 pub hits: Vec<PaperHit>,
1091 pub seed_openalex_id: String,
1093 pub seed_title: Option<String>,
1095 pub total_citing: Option<u64>,
1098}
1099
1100pub async fn frontier_view(
1119 query: &FrontierQuery,
1120 base: &Url,
1121 contact_email: &str,
1122 ctx: &FetchContext,
1123) -> Result<FrontierResults, FetchError> {
1124 let limit = query.limit.clamp(1, MAX_PER_PAGE);
1125
1126 let seed_url = {
1130 let mut u = base.join("/works").map_err(|e| FetchError::SourceSchema {
1131 hint: format!("frontier seed URL construction failed: {e}"),
1132 })?;
1133 {
1134 let mut qp = u.query_pairs_mut();
1135 qp.append_pair("filter", &format!("doi:{}", query.seed_doi.as_str()));
1136 qp.append_pair("per-page", "1");
1137 qp.append_pair("select", "id,title,display_name");
1138 if !contact_email.is_empty() {
1139 qp.append_pair("mailto", contact_email);
1140 }
1141 }
1142 u
1143 };
1144 let (seed_resp, _) = openalex_get(&seed_url, ctx).await?;
1145 let seed_results = seed_resp
1146 .get("results")
1147 .and_then(serde_json::Value::as_array)
1148 .ok_or_else(|| missing_results_array("frontier/seed", &seed_resp))?;
1149 let seed_work = seed_results.first().ok_or_else(|| FetchError::NotFound {
1150 hint: format!(
1151 "no OpenAlex work matched seed doi '{}'",
1152 query.seed_doi.as_str()
1153 ),
1154 })?;
1155 let seed_openalex_id = seed_work
1156 .get("id")
1157 .and_then(serde_json::Value::as_str)
1158 .map(strip_openalex_prefix)
1159 .ok_or_else(|| FetchError::SourceSchema {
1160 hint: format!(
1161 "seed OpenAlex record for '{}' has no id",
1162 query.seed_doi.as_str()
1163 ),
1164 })?
1165 .to_string();
1166 let seed_title = seed_work
1167 .get("title")
1168 .and_then(serde_json::Value::as_str)
1169 .or_else(|| {
1170 seed_work
1171 .get("display_name")
1172 .and_then(serde_json::Value::as_str)
1173 })
1174 .map(str::to_string);
1175
1176 let mut citing_url = base.clone();
1178 citing_url.set_path("/works");
1179 {
1180 let mut pairs = citing_url.query_pairs_mut();
1181 pairs.append_pair("filter", &format!("cites:{seed_openalex_id}"));
1182 pairs.append_pair("select", SELECT_FIELDS);
1183 pairs.append_pair("sort", "fwci:desc");
1184 pairs.append_pair("per-page", &limit.to_string());
1185 if !contact_email.is_empty() {
1186 pairs.append_pair("mailto", contact_email);
1187 }
1188 }
1189 let (citing_resp, _) = openalex_get(&citing_url, ctx).await?;
1190 let total_citing = citing_resp
1191 .get("meta")
1192 .and_then(|m| m.get("count"))
1193 .and_then(serde_json::Value::as_u64);
1194 let results_arr = citing_resp
1195 .get("results")
1196 .and_then(serde_json::Value::as_array)
1197 .ok_or_else(|| missing_results_array("frontier/cites", &citing_resp))?;
1198
1199 let mut hits: Vec<PaperHit> = results_arr
1201 .iter()
1202 .map(work_to_hit)
1203 .filter(|h| {
1204 query
1205 .min_year
1206 .is_none_or(|y| h.year.is_some_and(|hy| hy >= y))
1207 })
1208 .collect();
1209
1210 hits.sort_by(|a, b| {
1211 let fwci_ord = match (a.fwci, b.fwci) {
1212 (Some(fa), Some(fb)) => fb.partial_cmp(&fa).unwrap_or(std::cmp::Ordering::Equal),
1213 (Some(_), None) => std::cmp::Ordering::Less,
1214 (None, Some(_)) => std::cmp::Ordering::Greater,
1215 (None, None) => std::cmp::Ordering::Equal,
1216 };
1217 fwci_ord
1218 .then_with(|| b.year.cmp(&a.year))
1219 .then_with(|| b.cited_by_count.cmp(&a.cited_by_count))
1220 });
1221
1222 Ok(FrontierResults {
1223 hits,
1224 seed_openalex_id,
1225 seed_title,
1226 total_citing,
1227 })
1228}
1229
1230#[must_use]
1253pub fn zero_result_hint(query: &str) -> Option<String> {
1254 const DEGRADES_PAST: usize = 8;
1258
1259 let terms = query.split_whitespace().count();
1260 if terms <= DEGRADES_PAST {
1261 return None;
1262 }
1263 Some(format!(
1264 "This query has {terms} terms. OpenAlex free-text matching degrades sharply past roughly {DEGRADES_PAST} and returns nothing rather than a partial match, so zero results here is more likely to be about the query than about the literature. Retry with 3-5 distinctive terms - an author surname, a coined phrase, the distinguishing noun - before concluding the work is not indexed."
1265 ))
1266}
1267
1268#[cfg(test)]
1269#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1270mod tests {
1271 #[test]
1275 fn a_long_zero_result_query_is_told_why_it_may_be_zero() {
1276 let q = "lithium refractoriness after discontinuation kindling sensitization course of illness Post";
1277 let hint = zero_result_hint(q).expect("10 terms is past the threshold");
1278 assert!(hint.contains("10 terms"), "names the count: {hint}");
1279 assert!(
1280 hint.contains("3-5"),
1281 "says what to do instead, not only what went wrong: {hint}"
1282 );
1283 }
1284
1285 #[test]
1288 fn a_short_zero_result_query_is_left_alone() {
1289 assert!(zero_result_hint("depersonalization derealization").is_none());
1290 assert!(zero_result_hint("").is_none());
1291 }
1292
1293 #[test]
1296 fn the_threshold_counts_terms_not_length() {
1297 assert!(zero_result_hint(&"a".repeat(400)).is_none());
1298 assert!(zero_result_hint("a b c d e f g h i").is_some());
1299 }
1300
1301 use super::*;
1302
1303 use std::sync::Arc;
1304
1305 use camino::Utf8PathBuf;
1306 use tempfile::TempDir;
1307 use wiremock::matchers::{method, path, query_param};
1308 use wiremock::{Mock, MockServer, ResponseTemplate};
1309
1310 use crate::http::HttpClient;
1311 use crate::provenance::ProvenanceLog;
1312 use crate::rate_limiter::RateLimiter;
1313 use crate::RateLimits;
1314
1315 const SAMPLE_SEARCH: &str = r#"{
1320 "meta": { "count": 4012, "per_page": 25 },
1321 "results": [
1322 {
1323 "id": "https://openalex.org/W123",
1324 "doi": "https://doi.org/10.1234/Example",
1325 "title": "Tropical Tensor Networks",
1326 "display_name": "Tropical Tensor Networks",
1327 "publication_year": 2021,
1328 "cited_by_count": 42,
1329 "abstract_inverted_index": { "Tropical": [0], "tensor": [1], "networks": [2] },
1330 "authorships": [
1331 { "author": { "display_name": "Ada Lovelace" } },
1332 { "author": { "display_name": "Alan Turing" } }
1333 ],
1334 "primary_location": { "source": { "display_name": "Phys. Rev. B" } },
1335 "open_access": { "oa_status": "green", "is_oa": true },
1336 "locations": [
1337 { "landing_page_url": "https://arxiv.org/abs/2101.12345v2" }
1338 ]
1339 },
1340 {
1341 "id": "https://openalex.org/W456",
1342 "doi": null,
1343 "title": "Second Paper",
1344 "publication_year": 2019,
1345 "cited_by_count": 7,
1346 "abstract_inverted_index": null,
1347 "authorships": [],
1348 "open_access": { "oa_status": "closed" }
1349 }
1350 ]
1351 }"#;
1352
1353 fn build_test_context(wiremock_host: &str) -> (TempDir, FetchContext) {
1354 let td = TempDir::new().expect("tempdir");
1355 let log_dir =
1356 Utf8PathBuf::try_from(td.path().to_path_buf()).expect("temp dir path must be UTF-8");
1357 let log_path = log_dir.join("test.jsonl");
1358
1359 let http = Arc::new(HttpClient::new_for_tests_allow_http(
1360 "openalex",
1361 wiremock_host,
1362 ));
1363 let rate_limiter = Arc::new(RateLimiter::new(RateLimits::HARD_CODED));
1364 let session_id = "01J0000000000000000000TEST".to_string();
1365 let log = Arc::new(
1366 ProvenanceLog::open(log_path, session_id.clone()).expect("provenance log opens"),
1367 );
1368 let ctx = FetchContext {
1369 http,
1370 rate_limiter,
1371 log,
1372 session_id,
1373 cache_root: None,
1374 };
1375 (td, ctx)
1376 }
1377
1378 #[tokio::test]
1379 async fn search_maps_works_to_hits() {
1380 let server = MockServer::start().await;
1381 Mock::given(method("GET"))
1382 .and(path("/works"))
1383 .and(query_param(
1385 "filter",
1386 "title_and_abstract.search:tropical tensor networks",
1387 ))
1388 .and(query_param("mailto", "doiget@localhost"))
1389 .respond_with(ResponseTemplate::new(200).set_body_string(SAMPLE_SEARCH))
1390 .mount(&server)
1391 .await;
1392
1393 let (_td, ctx) = build_test_context(&server.uri());
1394 let base = Url::parse(&server.uri()).expect("wiremock URI parses");
1395 let q = PaperSearchQuery::new("tropical tensor networks");
1396
1397 let out = paper_search(&base, "doiget@localhost", &q, &ctx)
1398 .await
1399 .expect("search ok");
1400
1401 assert_eq!(out.total_results, Some(4012));
1402 assert_eq!(out.results.len(), 2);
1403
1404 let first = &out.results[0];
1405 assert_eq!(first.openalex_id, "W123");
1406 assert_eq!(first.doi.as_deref(), Some("10.1234/example")); assert_eq!(first.title, "Tropical Tensor Networks");
1408 assert_eq!(first.year, Some(2021));
1409 assert_eq!(first.cited_by_count, 42);
1410 assert_eq!(first.abstract_.as_deref(), Some("Tropical tensor networks"));
1411 assert_eq!(first.authors, vec!["Ada Lovelace", "Alan Turing"]);
1412 assert_eq!(first.venue.as_deref(), Some("Phys. Rev. B"));
1413 assert_eq!(first.oa_status.as_deref(), Some("green"));
1414 assert_eq!(first.arxiv.as_deref(), Some("2101.12345v2"));
1415 assert_eq!(first.source, DiscoverySource::OpenAlex);
1416
1417 let second = &out.results[1];
1418 assert_eq!(second.openalex_id, "W456");
1419 assert_eq!(second.doi, None);
1420 assert_eq!(second.abstract_, None);
1421 assert_eq!(second.venue, None);
1422 assert!(second.authors.is_empty());
1423 assert_eq!(second.oa_status.as_deref(), Some("closed"));
1424 assert_eq!(second.arxiv, None);
1425 }
1426
1427 #[tokio::test]
1428 async fn search_filters_and_sort_land_on_the_url() {
1429 let server = MockServer::start().await;
1430 Mock::given(method("GET"))
1432 .and(path("/works"))
1433 .and(query_param("sort", "relevance_score:desc"))
1436 .and(query_param(
1437 "filter",
1438 "title_and_abstract.search:spin glass,from_publication_date:2020-01-01,is_oa:true,cited_by_count:>10",
1439 ))
1440 .and(query_param("per-page", "5"))
1441 .respond_with(
1442 ResponseTemplate::new(200)
1443 .set_body_string(r#"{ "meta": { "count": 0 }, "results": [] }"#),
1444 )
1445 .mount(&server)
1446 .await;
1447
1448 let (_td, ctx) = build_test_context(&server.uri());
1449 let base = Url::parse(&server.uri()).expect("wiremock URI parses");
1450 let q = PaperSearchQuery {
1451 query: "spin glass".to_string(),
1452 limit: 5,
1453 from_year: Some(2020),
1454 to_year: None,
1455 oa_only: true,
1456 min_citations: Some(10),
1457 min_fwci: None,
1458 min_percentile: None,
1459 author: None,
1460 venue: None,
1461 publisher: None,
1462 sort: SearchSort::Relevance,
1463 };
1464
1465 let out = paper_search(&base, "doiget@localhost", &q, &ctx)
1466 .await
1467 .expect("search ok");
1468 assert_eq!(out.total_results, Some(0));
1469 assert!(out.results.is_empty());
1470 }
1471
1472 #[tokio::test]
1473 async fn search_error_payload_is_source_schema() {
1474 let server = MockServer::start().await;
1475 Mock::given(method("GET"))
1476 .and(path("/works"))
1477 .respond_with(
1478 ResponseTemplate::new(200)
1479 .set_body_string(r#"{"error":"Invalid query parameters"}"#),
1480 )
1481 .mount(&server)
1482 .await;
1483
1484 let (_td, ctx) = build_test_context(&server.uri());
1485 let base = Url::parse(&server.uri()).expect("wiremock URI parses");
1486 let q = PaperSearchQuery::new("anything");
1487
1488 let err = paper_search(&base, "", &q, &ctx)
1489 .await
1490 .expect_err("missing `results` must surface as SourceSchema");
1491 assert!(matches!(err, FetchError::SourceSchema { .. }));
1492 }
1493
1494 #[test]
1495 fn name_filters_compose_into_resolved_ids() {
1496 let base = Url::parse("https://api.openalex.org").expect("base parses");
1497 let q = PaperSearchQuery::new("topic");
1498 let ids = ResolvedIds {
1499 author: Some("A1".to_string()),
1500 source: Some("S2".to_string()),
1501 publisher: Some("P3".to_string()),
1502 };
1503 let url = build_search_url(&base, "", &q, &ids).expect("url builds");
1504 let filter = url
1505 .query_pairs()
1506 .find(|(k, _)| k == "filter")
1507 .map(|(_, v)| v.into_owned())
1508 .expect("filter param present");
1509 assert!(filter.contains("authorships.author.id:A1"), "got {filter}");
1510 assert!(
1511 filter.contains("primary_location.source.id:S2"),
1512 "got {filter}"
1513 );
1514 assert!(
1515 filter.contains("primary_location.source.publisher_lineage:P3"),
1516 "got {filter}"
1517 );
1518 assert!(
1521 param(&url, "mailto").is_none(),
1522 "empty contact email must omit mailto"
1523 );
1524 }
1525
1526 #[tokio::test]
1527 async fn venue_name_resolves_to_source_id_then_filters_works() {
1528 let server = MockServer::start().await;
1529 Mock::given(method("GET"))
1531 .and(path("/sources"))
1532 .and(query_param("search", "Physical Review B"))
1533 .respond_with(ResponseTemplate::new(200).set_body_string(
1534 r#"{ "results": [ { "id": "https://openalex.org/S99", "display_name": "Physical Review B" } ] }"#,
1535 ))
1536 .mount(&server)
1537 .await;
1538 Mock::given(method("GET"))
1540 .and(path("/works"))
1541 .and(query_param(
1542 "filter",
1543 "title_and_abstract.search:spin glass,primary_location.source.id:S99",
1544 ))
1545 .respond_with(ResponseTemplate::new(200).set_body_string(
1546 r#"{ "meta": { "count": 1 }, "results": [ { "id": "https://openalex.org/W1", "title": "In PRB" } ] }"#,
1547 ))
1548 .mount(&server)
1549 .await;
1550
1551 let (_td, ctx) = build_test_context(&server.uri());
1552 let base = Url::parse(&server.uri()).expect("wiremock URI parses");
1553 let mut q = PaperSearchQuery::new("spin glass");
1554 q.venue = Some("Physical Review B".to_string());
1555
1556 let out = paper_search(&base, "", &q, &ctx)
1557 .await
1558 .expect("venue-filtered search ok");
1559 assert_eq!(out.total_results, Some(1));
1560 assert_eq!(out.results.len(), 1);
1561 assert_eq!(out.results[0].openalex_id, "W1");
1562 }
1563
1564 #[tokio::test]
1565 async fn unresolvable_venue_name_is_not_found() {
1566 let server = MockServer::start().await;
1567 Mock::given(method("GET"))
1568 .and(path("/sources"))
1569 .respond_with(ResponseTemplate::new(200).set_body_string(r#"{ "results": [] }"#))
1570 .mount(&server)
1571 .await;
1572
1573 let (_td, ctx) = build_test_context(&server.uri());
1574 let base = Url::parse(&server.uri()).expect("wiremock URI parses");
1575 let mut q = PaperSearchQuery::new("spin glass");
1576 q.venue = Some("No Such Journal".to_string());
1577
1578 let err = paper_search(&base, "", &q, &ctx)
1579 .await
1580 .expect_err("an unresolvable venue name must error, not silently drop the filter");
1581 assert!(matches!(err, FetchError::NotFound { .. }), "got {err:?}");
1582 }
1583
1584 #[tokio::test]
1585 async fn entity_error_envelope_is_source_schema_not_not_found() {
1586 let server = MockServer::start().await;
1587 Mock::given(method("GET"))
1591 .and(path("/authors"))
1592 .respond_with(
1593 ResponseTemplate::new(200).set_body_string(r#"{"error":"rate limit exceeded"}"#),
1594 )
1595 .mount(&server)
1596 .await;
1597
1598 let (_td, ctx) = build_test_context(&server.uri());
1599 let base = Url::parse(&server.uri()).expect("wiremock URI parses");
1600 let mut q = PaperSearchQuery::new("x");
1601 q.author = Some("Parisi".to_string());
1602
1603 let err = paper_search(&base, "", &q, &ctx)
1604 .await
1605 .expect_err("an entity error envelope must be SourceSchema, not NotFound");
1606 assert!(
1607 matches!(err, FetchError::SourceSchema { .. }),
1608 "got {err:?}"
1609 );
1610 }
1611
1612 #[tokio::test]
1613 async fn exact_name_match_resolves_amid_namesakes() {
1614 let server = MockServer::start().await;
1615 Mock::given(method("GET"))
1617 .and(path("/sources"))
1618 .respond_with(ResponseTemplate::new(200).set_body_string(
1619 r#"{ "results": [
1620 { "id": "https://openalex.org/S1", "display_name": "Physical Review B", "works_count": 50000, "relevance_score": 80.0 },
1621 { "id": "https://openalex.org/S2", "display_name": "Physical Review B: Condensed Matter", "works_count": 1000, "relevance_score": 78.0 },
1622 { "id": "https://openalex.org/S3", "display_name": "Reviews of Physics", "works_count": 200, "relevance_score": 70.0 }
1623 ] }"#,
1624 ))
1625 .mount(&server)
1626 .await;
1627 Mock::given(method("GET"))
1628 .and(path("/works"))
1629 .and(query_param(
1630 "filter",
1631 "title_and_abstract.search:spin glass,primary_location.source.id:S1",
1632 ))
1633 .respond_with(ResponseTemplate::new(200).set_body_string(
1634 r#"{ "meta": { "count": 1 }, "results": [ { "id": "https://openalex.org/W1", "title": "x" } ] }"#,
1635 ))
1636 .mount(&server)
1637 .await;
1638
1639 let (_td, ctx) = build_test_context(&server.uri());
1640 let base = Url::parse(&server.uri()).expect("wiremock URI parses");
1641 let mut q = PaperSearchQuery::new("spin glass");
1642 q.venue = Some("Physical Review B".to_string());
1643
1644 let out = paper_search(&base, "", &q, &ctx)
1645 .await
1646 .expect("exact venue name must resolve to S1 amid namesakes");
1647 assert_eq!(out.results[0].openalex_id, "W1");
1648 }
1649
1650 #[tokio::test]
1651 async fn dominant_top_hit_resolves_for_vague_name() {
1652 let server = MockServer::start().await;
1653 Mock::given(method("GET"))
1655 .and(path("/authors"))
1656 .respond_with(ResponseTemplate::new(200).set_body_string(
1657 r#"{ "results": [
1658 { "id": "https://openalex.org/A1", "display_name": "Giorgio Parisi", "works_count": 400, "relevance_score": 100.0 },
1659 { "id": "https://openalex.org/A2", "display_name": "M. Parisi", "works_count": 10, "relevance_score": 20.0 }
1660 ] }"#,
1661 ))
1662 .mount(&server)
1663 .await;
1664 Mock::given(method("GET"))
1665 .and(path("/works"))
1666 .and(query_param(
1667 "filter",
1668 "title_and_abstract.search:replica symmetry breaking,authorships.author.id:A1",
1669 ))
1670 .respond_with(ResponseTemplate::new(200).set_body_string(
1671 r#"{ "meta": { "count": 1 }, "results": [ { "id": "https://openalex.org/W9", "title": "y" } ] }"#,
1672 ))
1673 .mount(&server)
1674 .await;
1675
1676 let (_td, ctx) = build_test_context(&server.uri());
1677 let base = Url::parse(&server.uri()).expect("wiremock URI parses");
1678 let mut q = PaperSearchQuery::new("replica symmetry breaking");
1679 q.author = Some("parisi".to_string());
1680
1681 let out = paper_search(&base, "", &q, &ctx)
1682 .await
1683 .expect("a dominant top hit must resolve a vague name");
1684 assert_eq!(out.results[0].openalex_id, "W9");
1685 }
1686
1687 #[tokio::test]
1688 async fn ambiguous_name_errors_with_candidate_listing() {
1689 let server = MockServer::start().await;
1690 Mock::given(method("GET"))
1692 .and(path("/authors"))
1693 .respond_with(ResponseTemplate::new(200).set_body_string(
1694 r#"{ "results": [
1695 { "id": "https://openalex.org/A1", "display_name": "John Smith", "works_count": 300, "relevance_score": 50.0 },
1696 { "id": "https://openalex.org/A2", "display_name": "Jane Smith", "works_count": 280, "relevance_score": 45.0 }
1697 ] }"#,
1698 ))
1699 .mount(&server)
1700 .await;
1701
1702 let (_td, ctx) = build_test_context(&server.uri());
1703 let base = Url::parse(&server.uri()).expect("wiremock URI parses");
1704 let mut q = PaperSearchQuery::new("electrons");
1705 q.author = Some("Smith".to_string());
1706
1707 let err = paper_search(&base, "", &q, &ctx)
1708 .await
1709 .expect_err("a close, non-exact multi-match must be reported as ambiguous");
1710 match err {
1711 FetchError::Ambiguous { hint } => {
1712 assert!(hint.contains("John Smith"), "hint lists candidates: {hint}");
1713 assert!(hint.contains("Jane Smith"), "hint lists candidates: {hint}");
1714 }
1715 other => panic!("expected Ambiguous, got {other:?}"),
1716 }
1717 }
1718
1719 #[test]
1720 fn abstract_reconstruction_orders_by_position() {
1721 let inv = serde_json::json!({
1722 "world": [1],
1723 "hello": [0],
1724 "again": [3],
1725 "hello2": [2]
1726 });
1727 assert_eq!(
1729 reconstruct_abstract(&inv).as_deref(),
1730 Some("hello world hello2 again")
1731 );
1732 assert_eq!(reconstruct_abstract(&serde_json::Value::Null), None);
1733 assert_eq!(reconstruct_abstract(&serde_json::json!({})), None);
1734 }
1735
1736 #[test]
1737 fn doi_and_openalex_prefixes_are_stripped() {
1738 assert_eq!(
1739 strip_doi_prefix("https://doi.org/10.1234/ABC"),
1740 "10.1234/abc"
1741 );
1742 assert_eq!(strip_openalex_prefix("https://openalex.org/W999"), "W999");
1743 }
1744
1745 #[tokio::test]
1748 async fn doi_lookup_extracts_arxiv_preprint() {
1749 let server = MockServer::start().await;
1750 Mock::given(method("GET"))
1751 .and(path("/works"))
1752 .and(query_param("filter", "doi:10.1103/physrevb.1"))
1753 .respond_with(ResponseTemplate::new(200).set_body_string(
1754 r#"{ "meta": { "count": 1 }, "results": [ {
1755 "id": "https://openalex.org/W55",
1756 "doi": "https://doi.org/10.1103/PhysRevB.1",
1757 "title": "Published Version",
1758 "locations": [
1759 { "landing_page_url": "https://journals.aps.org/prb/abstract/x" },
1760 { "pdf_url": "https://arxiv.org/abs/2101.54321v2" }
1761 ]
1762 } ] }"#,
1763 ))
1764 .mount(&server)
1765 .await;
1766
1767 let (_td, ctx) = build_test_context(&server.uri());
1768 let base = Url::parse(&server.uri()).expect("wiremock URI parses");
1769 let links = resolve_links_for_doi(&base, "", "10.1103/physrevb.1", &ctx)
1770 .await
1771 .expect("doi lookup ok");
1772 assert_eq!(links.openalex_id, "W55");
1773 assert_eq!(links.doi.as_deref(), Some("10.1103/physrevb.1")); assert_eq!(links.arxiv.as_deref(), Some("2101.54321v2"));
1775 assert_eq!(links.title, "Published Version");
1776 }
1777
1778 #[tokio::test]
1779 async fn doi_lookup_without_arxiv_location_is_none() {
1780 let server = MockServer::start().await;
1781 Mock::given(method("GET"))
1782 .and(path("/works"))
1783 .respond_with(ResponseTemplate::new(200).set_body_string(
1784 r#"{ "meta": { "count": 1 }, "results": [ {
1785 "id": "https://openalex.org/W7",
1786 "doi": "https://doi.org/10.1234/closed",
1787 "title": "No Preprint",
1788 "locations": [ { "landing_page_url": "https://example.com/x" } ]
1789 } ] }"#,
1790 ))
1791 .mount(&server)
1792 .await;
1793
1794 let (_td, ctx) = build_test_context(&server.uri());
1795 let base = Url::parse(&server.uri()).expect("uri");
1796 let links = resolve_links_for_doi(&base, "", "10.1234/closed", &ctx)
1797 .await
1798 .expect("ok");
1799 assert_eq!(links.arxiv, None);
1800 assert_eq!(links.openalex_id, "W7");
1801 }
1802
1803 #[tokio::test]
1804 async fn doi_lookup_unknown_doi_is_not_found() {
1805 let server = MockServer::start().await;
1806 Mock::given(method("GET"))
1807 .and(path("/works"))
1808 .respond_with(
1809 ResponseTemplate::new(200)
1810 .set_body_string(r#"{ "meta": { "count": 0 }, "results": [] }"#),
1811 )
1812 .mount(&server)
1813 .await;
1814
1815 let (_td, ctx) = build_test_context(&server.uri());
1816 let base = Url::parse(&server.uri()).expect("uri");
1817 let err = resolve_links_for_doi(&base, "", "10.0000/nope", &ctx)
1818 .await
1819 .expect_err("an unmatched doi must be NotFound");
1820 assert!(matches!(err, FetchError::NotFound { .. }), "got {err:?}");
1821 }
1822
1823 #[test]
1824 fn doi_lookup_url_preserves_input_doi_case() {
1825 let base = Url::parse("https://api.openalex.org").expect("base");
1829 let u = build_doi_lookup_url(&base, "", "10.1103/PhysRevB.1").expect("url");
1830 assert_eq!(
1831 param(&u, "filter").as_deref(),
1832 Some("doi:10.1103/PhysRevB.1"),
1833 "the input DOI case must be carried through verbatim"
1834 );
1835 }
1836
1837 #[test]
1838 fn doi_lookup_url_carries_filter_and_select() {
1839 let base = Url::parse("https://api.openalex.org").expect("base");
1840 let u = build_doi_lookup_url(&base, "", "10.1/x").expect("url");
1841 assert_eq!(
1842 param(&u, "filter").as_deref(),
1843 Some("doi:10.1/x"),
1844 "doi filter must be url-encoded into the query"
1845 );
1846 assert!(param(&u, "select")
1847 .unwrap_or_default()
1848 .contains("locations"));
1849 assert_eq!(param(&u, "per-page").as_deref(), Some("1"));
1850 }
1851
1852 fn param(u: &Url, key: &str) -> Option<String> {
1855 u.query_pairs()
1856 .find(|(k, _)| k == key)
1857 .map(|(_, v)| v.into_owned())
1858 }
1859
1860 #[test]
1861 fn per_page_clamps_to_floor_and_ceiling() {
1862 let base = Url::parse("https://api.openalex.org").expect("base");
1863 let mut q = PaperSearchQuery::new("x");
1864 q.limit = 0;
1865 let u = build_search_url(&base, "", &q, &ResolvedIds::default()).expect("url");
1866 assert_eq!(param(&u, "per-page").as_deref(), Some("1"), "limit 0 -> 1");
1867 q.limit = 201;
1868 let u = build_search_url(&base, "", &q, &ResolvedIds::default()).expect("url");
1869 assert_eq!(
1870 param(&u, "per-page").as_deref(),
1871 Some("200"),
1872 "limit 201 -> 200"
1873 );
1874 }
1875
1876 #[test]
1877 fn to_year_filter_and_relevance_only_sort_land_on_url() {
1878 let base = Url::parse("https://api.openalex.org").expect("base");
1879 let mut q = PaperSearchQuery::new("x");
1880 q.to_year = Some(2023);
1881 let u = build_search_url(&base, "", &q, &ResolvedIds::default()).expect("url");
1882 assert_eq!(param(&u, "sort").as_deref(), Some("relevance_score:desc"));
1884 assert!(
1885 param(&u, "filter")
1886 .unwrap_or_default()
1887 .contains("to_publication_date:2023-12-31"),
1888 "to_year must map to to_publication_date:<y>-12-31"
1889 );
1890 }
1891
1892 #[test]
1893 fn query_is_a_title_and_abstract_filter_not_search_param() {
1894 let base = Url::parse("https://api.openalex.org").expect("base");
1895 let mut q = PaperSearchQuery::new("classical shadows");
1896 q.min_fwci = Some(5.0);
1897 q.min_percentile = Some(90);
1898 let u = build_search_url(&base, "", &q, &ResolvedIds::default()).expect("url");
1899 assert_eq!(param(&u, "search"), None, "no loose `search=` param");
1901 let filter = param(&u, "filter").unwrap_or_default();
1902 assert!(
1903 filter.contains("title_and_abstract.search:classical shadows"),
1904 "query must be a title_and_abstract.search filter: {filter}"
1905 );
1906 assert!(filter.contains("fwci:>5"), "min_fwci filter: {filter}");
1907 assert!(
1908 filter.contains("cited_by_percentile_year.min:90"),
1909 "min_percentile filter: {filter}"
1910 );
1911 }
1912
1913 fn cand(id: &str, name: &str, works: u64, rel: f64) -> Candidate {
1916 Candidate {
1917 id: id.to_string(),
1918 display_name: name.to_string(),
1919 works_count: works,
1920 relevance: rel,
1921 }
1922 }
1923
1924 #[test]
1925 fn dominance_at_exactly_2x_resolves_top() {
1926 let c = vec![cand("A1", "x", 1, 2.0), cand("A2", "y", 1, 1.0)];
1927 assert_eq!(select_entity("authors", "q", &c).expect("resolves"), "A1");
1928 }
1929
1930 #[test]
1931 fn dominance_just_below_2x_is_ambiguous() {
1932 let c = vec![cand("A1", "x", 1, 1.9), cand("A2", "y", 1, 1.0)];
1933 assert!(matches!(
1934 select_entity("authors", "q", &c),
1935 Err(FetchError::Ambiguous { .. })
1936 ));
1937 }
1938
1939 #[test]
1940 fn zero_relevance_runner_up_is_ambiguous_not_auto_top() {
1941 let c = vec![cand("A1", "x", 1, 5.0), cand("A2", "y", 1, 0.0)];
1944 assert!(matches!(
1945 select_entity("authors", "q", &c),
1946 Err(FetchError::Ambiguous { .. })
1947 ));
1948 }
1949
1950 #[test]
1951 fn multiple_exact_name_matches_are_ambiguous() {
1952 let c = vec![cand("S1", "Dup", 9, 5.0), cand("S2", "Dup", 1, 1.0)];
1955 assert!(matches!(
1956 select_entity("sources", "Dup", &c),
1957 Err(FetchError::Ambiguous { .. })
1958 ));
1959 }
1960
1961 #[test]
1964 fn arxiv_extracted_from_pdf_url_when_landing_absent() {
1965 let loc = serde_json::json!({ "pdf_url": "https://arxiv.org/abs/2302.00001v3" });
1966 assert_eq!(
1967 extract_arxiv_from_location(&loc).as_deref(),
1968 Some("2302.00001v3")
1969 );
1970 }
1971
1972 #[test]
1973 fn arxiv_id_stops_at_query_string() {
1974 let loc =
1975 serde_json::json!({ "landing_page_url": "https://arxiv.org/abs/2101.12345?utm=x" });
1976 assert_eq!(
1977 extract_arxiv_from_location(&loc).as_deref(),
1978 Some("2101.12345")
1979 );
1980 }
1981
1982 #[test]
1983 fn arxiv_extracted_old_style_id_not_truncated() {
1984 let loc =
1986 serde_json::json!({ "landing_page_url": "https://arxiv.org/abs/cond-mat/0701105" });
1987 assert_eq!(
1988 extract_arxiv_from_location(&loc).as_deref(),
1989 Some("cond-mat/0701105")
1990 );
1991 let loc2 = serde_json::json!({
1993 "pdf_url": "http://arxiv.org/abs/astro-ph.CO/0703123v2?foo=bar"
1994 });
1995 assert_eq!(
1996 extract_arxiv_from_location(&loc2).as_deref(),
1997 Some("astro-ph.CO/0703123v2")
1998 );
1999 }
2000
2001 #[test]
2002 fn arxiv_extraction_rejects_garbage() {
2003 let loc = serde_json::json!({ "landing_page_url": "https://arxiv.org/abs/not an id!" });
2006 assert_eq!(extract_arxiv_from_location(&loc), None);
2007 }
2008
2009 #[test]
2010 fn truncate_for_hint_is_char_boundary_safe() {
2011 let body = "あ".repeat(300);
2013 let out = truncate_for_hint(body.as_bytes());
2014 assert!(out.ends_with('…'));
2015 assert_eq!(out.chars().filter(|&c| c == 'あ').count(), 200);
2016 }
2017
2018 #[test]
2019 fn ambiguous_has_its_own_wire_code() {
2020 let e = FetchError::Ambiguous { hint: "x".into() };
2022 assert_eq!(crate::ErrorCode::from(&e), crate::ErrorCode::Ambiguous);
2023 assert_eq!(crate::ErrorCode::Ambiguous.as_wire(), "AMBIGUOUS");
2024 }
2025
2026 #[test]
2027 fn validate_rejects_bad_shape_and_accepts_good() {
2028 let mut q = PaperSearchQuery::new("topic");
2029 assert!(q.validate().is_ok());
2030
2031 q.query = " ".to_string();
2032 assert!(q.validate().unwrap_err().contains("empty"));
2033
2034 let mut q = PaperSearchQuery::new("topic");
2035 q.limit = 0;
2036 assert!(q.validate().unwrap_err().contains("limit"));
2037 q.limit = MAX_PER_PAGE + 1;
2038 assert!(q.validate().unwrap_err().contains("limit"));
2039
2040 let mut q = PaperSearchQuery::new("topic");
2041 q.from_year = Some(2025);
2042 q.to_year = Some(2010);
2043 assert!(q.validate().unwrap_err().contains("after"));
2044 q.to_year = Some(2025);
2046 assert!(q.validate().is_ok());
2047 }
2048
2049 #[test]
2050 fn validate_rejects_out_of_range_impact_filters() {
2051 let mut q = PaperSearchQuery::new("topic");
2055 q.min_fwci = Some(-1.0);
2056 assert!(q.validate().unwrap_err().contains("min_fwci"));
2057 q.min_fwci = Some(f64::NAN);
2058 assert!(q.validate().unwrap_err().contains("min_fwci"));
2059 q.min_fwci = Some(f64::INFINITY);
2060 assert!(q.validate().unwrap_err().contains("min_fwci"));
2061 q.min_fwci = Some(2.5);
2063 assert!(q.validate().is_ok());
2064
2065 let mut q = PaperSearchQuery::new("topic");
2066 q.min_percentile = Some(101);
2067 assert!(q.validate().unwrap_err().contains("min_percentile"));
2068 q.min_percentile = Some(100);
2071 assert!(q.validate().is_ok());
2072 q.min_percentile = Some(0);
2073 assert!(q.validate().is_ok());
2074 }
2075}