use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use super::Paper;
const FIELDS: &str = "title,abstract,year,citationCount,authors,url,publicationDate,externalIds,fieldsOfStudy,openAccessPdf,venue";
const USER_AGENT: &str = concat!(
"fastpaper-cli/",
env!("CARGO_PKG_VERSION"),
" (+https://github.com/zhangyee/fastpaper-cli)"
);
const MIN_INTERVAL_AUTH: Duration = Duration::from_millis(1000);
const MIN_INTERVAL_ANON: Duration = Duration::from_millis(100);
const MAX_RETRIES: u32 = 5;
#[derive(Clone, Copy)]
struct BackoffConfig {
base: Duration,
max: Duration,
max_retries: u32,
}
impl BackoffConfig {
const DEFAULT_AUTH: Self = Self {
base: Duration::from_secs(1),
max: Duration::from_secs(30),
max_retries: MAX_RETRIES,
};
const DEFAULT_ANON: Self = Self {
base: Duration::from_secs(2),
max: Duration::from_secs(30),
max_retries: MAX_RETRIES,
};
}
enum FetchOutcome {
Ok(String),
RateLimited,
Err(String),
}
static LAST_CALL: OnceLock<Mutex<Option<Instant>>> = OnceLock::new();
fn parse_retry_after(headers: &ureq::http::HeaderMap, max: Duration) -> Option<Duration> {
headers
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.trim().parse::<u64>().ok())
.map(Duration::from_secs)
.map(|d| d.min(max))
}
fn backoff_delay(attempt: u32, cfg: &BackoffConfig) -> Duration {
let shift = attempt.min(10);
let exp_ms = (cfg.base.as_millis() as u64).saturating_mul(1u64 << shift);
let exp = Duration::from_millis(exp_ms).min(cfg.max);
let nanos = Instant::now().elapsed().subsec_nanos() as u64;
let jitter_pct = nanos % 11; let jitter_ms = (exp.as_millis() as u64).saturating_mul(jitter_pct) / 100;
exp + Duration::from_millis(jitter_ms)
}
fn throttle_with(state: &Mutex<Option<Instant>>, min_interval: Duration) {
let mut guard = state.lock().unwrap();
if let Some(last) = *guard {
let elapsed = last.elapsed();
if elapsed < min_interval {
std::thread::sleep(min_interval - elapsed);
}
}
*guard = Some(Instant::now());
}
fn throttle(has_key: bool) {
let min = if has_key {
MIN_INTERVAL_AUTH
} else {
MIN_INTERVAL_ANON
};
let state = LAST_CALL.get_or_init(|| Mutex::new(None));
throttle_with(state, min);
}
fn http_get_with_retry_cfg(
url: &str,
mut api_key: Option<String>,
cfg: &BackoffConfig,
) -> FetchOutcome {
let mut tried_without_key = api_key.is_none();
let agent: ureq::Agent = ureq::Agent::config_builder()
.http_status_as_error(false)
.build()
.into();
let mut attempt: u32 = 0;
while attempt <= cfg.max_retries {
throttle(api_key.is_some());
let mut req = agent.get(url).header("User-Agent", USER_AGENT);
if let Some(ref k) = api_key {
req = req.header("x-api-key", k);
}
let resp = match req.call() {
Ok(r) => r,
Err(e) => return FetchOutcome::Err(format!("HTTP error: {e}")),
};
let status = resp.status().as_u16();
match status {
200 => {
return match resp.into_body().read_to_string() {
Ok(body) => FetchOutcome::Ok(body),
Err(e) => FetchOutcome::Err(format!("read body: {e}")),
};
}
403 if api_key.is_some() && !tried_without_key => {
eprintln!("[semantic] API key rejected (403), retrying without key");
api_key = None;
tried_without_key = true;
continue; }
404 => return FetchOutcome::Err("HTTP 404".to_string()),
429 => {
if attempt == cfg.max_retries {
return FetchOutcome::RateLimited;
}
let wait = parse_retry_after(resp.headers(), cfg.max)
.unwrap_or_else(|| backoff_delay(attempt, cfg));
std::thread::sleep(wait);
}
500..=599 => return FetchOutcome::Err(format!("Server error: {status}")),
_ => return FetchOutcome::Err(format!("HTTP {status}")),
}
attempt += 1;
}
FetchOutcome::RateLimited
}
const MAX_OFFSET: u32 = 1000;
fn build_search_url(base_url: &str, q: &super::SearchQuery) -> Result<String, String> {
if let Some(ref author) = q.author {
return Err(format!(
"semantic has no author filter on paper search (asked for --author {}).\n\
Try arxiv, crossref or openalex, which do.",
author
));
}
let sort = match q.sort {
None | Some(super::SortField::Relevance) => None,
Some(super::SortField::Date) => Some("publicationDate"),
Some(super::SortField::Citations) => Some("citationCount"),
};
let mut url = if sort.is_some() {
if q.offset > 0 {
return Err(
"semantic pages sorted results with a token, not an offset, so --offset cannot be \
combined with --sort date or --sort citations"
.to_string(),
);
}
format!(
"{}/graph/v1/paper/search/bulk?query={}&fields={}",
base_url,
super::encode_query(&q.query),
FIELDS
)
} else {
if q.offset > MAX_OFFSET {
return Err(format!(
"semantic supports an offset up to {} (asked for {})",
MAX_OFFSET, q.offset
));
}
format!(
"{}/graph/v1/paper/search?query={}&limit={}&offset={}&fields={}",
base_url,
super::encode_query(&q.query),
q.limit,
q.offset,
FIELDS
)
};
if let Some(by) = sort {
let order = match q.order {
super::SortOrder::Asc => "asc",
super::SortOrder::Desc => "desc",
};
url.push_str(&format!("&sort={}:{}", by, order));
}
match q.year {
Some(year) => url.push_str(&format!("&year={}", year)),
None => {
if q.after.is_some() || q.before.is_some() {
let from = match q.after {
Some(ref d) => super::validate_ymd(d)?,
None => "",
};
let to = match q.before {
Some(ref d) => super::validate_ymd(d)?,
None => "",
};
url.push_str(&format!("&publicationDateOrYear={}:{}", from, to));
}
}
}
if let Some(ref field) = q.field {
url.push_str(&format!("&fieldsOfStudy={}", super::encode_query(field)));
}
if q.open_access {
url.push_str("&openAccessPdf");
}
Ok(url)
}
pub fn search(base_url: &str, q: &super::SearchQuery) -> Result<Vec<Paper>, String> {
let url = build_search_url(base_url, q)?;
let truncate_to = if q.sort.is_some() && q.sort != Some(super::SortField::Relevance) {
Some(q.limit as usize)
} else {
None
};
let api_key = std::env::var("SEMANTIC_SCHOLAR_API_KEY").ok();
let cfg = if api_key.is_some() {
BackoffConfig::DEFAULT_AUTH
} else {
BackoffConfig::DEFAULT_ANON
};
match http_get_with_retry_cfg(&url, api_key, &cfg) {
FetchOutcome::Ok(body) => {
let mut papers = parse_search_response(&body)?;
if let Some(n) = truncate_to {
papers.truncate(n);
}
Ok(papers)
}
FetchOutcome::RateLimited => Err(format!("rate limited after {} retries", cfg.max_retries)),
FetchOutcome::Err(e) => Err(e),
}
}
#[cfg(test)]
fn search_with_cfg_for_test(
base_url: &str,
query: &str,
max_results: u32,
cfg: &BackoffConfig,
) -> Result<Vec<Paper>, String> {
let encoded = super::encode_query(query);
let url = format!(
"{}/graph/v1/paper/search?query={}&limit={}&fields={}",
base_url, encoded, max_results, FIELDS
);
let api_key = std::env::var("SEMANTIC_SCHOLAR_API_KEY").ok();
match http_get_with_retry_cfg(&url, api_key, cfg) {
FetchOutcome::Ok(body) => parse_search_response(&body),
FetchOutcome::RateLimited => Err(format!("rate limited after {} retries", cfg.max_retries)),
FetchOutcome::Err(e) => Err(e),
}
}
pub fn get_by_id(base_url: &str, s2_id: &str) -> Result<Option<Paper>, String> {
let url = format!("{}/graph/v1/paper/{}?fields={}", base_url, s2_id, FIELDS);
let api_key = std::env::var("SEMANTIC_SCHOLAR_API_KEY").ok();
let cfg = if api_key.is_some() {
BackoffConfig::DEFAULT_AUTH
} else {
BackoffConfig::DEFAULT_ANON
};
match http_get_with_retry_cfg(&url, api_key, &cfg) {
FetchOutcome::Ok(body) => {
let wrapped = format!(r#"{{"data":[{}]}}"#, body);
Ok(parse_search_response(&wrapped)?.into_iter().next())
}
FetchOutcome::Err(e) if e.contains("404") => Ok(None),
FetchOutcome::Err(e) => Err(e),
FetchOutcome::RateLimited => Err(format!("rate limited after {} retries", cfg.max_retries)),
}
}
#[cfg(test)]
fn get_by_id_with_cfg_for_test(
base_url: &str,
s2_id: &str,
cfg: &BackoffConfig,
) -> Result<Option<Paper>, String> {
let url = format!("{}/graph/v1/paper/{}?fields={}", base_url, s2_id, FIELDS);
let api_key = std::env::var("SEMANTIC_SCHOLAR_API_KEY").ok();
match http_get_with_retry_cfg(&url, api_key, cfg) {
FetchOutcome::Ok(body) => {
let wrapped = format!(r#"{{"data":[{}]}}"#, body);
Ok(parse_search_response(&wrapped)?.into_iter().next())
}
FetchOutcome::Err(e) if e.contains("404") => Ok(None),
FetchOutcome::Err(e) => Err(e),
FetchOutcome::RateLimited => Err(format!("rate limited after {} retries", cfg.max_retries)),
}
}
pub fn cite(
base_url: &str,
id: &str,
direction: super::Direction,
limit: u32,
) -> Result<Vec<Paper>, String> {
let endpoint = match direction {
super::Direction::Incoming => "citations",
super::Direction::Outgoing => "references",
};
let url = format!(
"{}/graph/v1/paper/{}/{}?limit={}&fields={}",
base_url, id, endpoint, limit, FIELDS
);
let key = std::env::var("SEMANTIC_SCHOLAR_API_KEY").ok();
let cfg = if key.is_some() {
BackoffConfig::DEFAULT_AUTH
} else {
BackoffConfig::DEFAULT_ANON
};
match http_get_with_retry_cfg(&url, key, &cfg) {
FetchOutcome::Ok(body) => parse_edge_response(&body, direction),
FetchOutcome::RateLimited => Err(
"semantic rate limited. Set SEMANTIC_SCHOLAR_API_KEY, or use: fastpaper cite openalex <id>"
.to_string(),
),
FetchOutcome::Err(e) => Err(e),
}
}
pub fn parse_edge_response(json: &str, direction: super::Direction) -> Result<Vec<Paper>, String> {
let root: serde_json::Value =
serde_json::from_str(json).map_err(|e| format!("JSON parse error: {}", e))?;
let data = root["data"].as_array().ok_or("missing 'data' array")?;
let key = match direction {
super::Direction::Incoming => "citingPaper",
super::Direction::Outgoing => "citedPaper",
};
Ok(data
.iter()
.map(|edge| &edge[key])
.filter(|p| !p.is_null())
.map(paper_from)
.filter(|p| !p.title.is_empty())
.collect())
}
pub fn parse_search_response(json: &str) -> Result<Vec<Paper>, String> {
let root: serde_json::Value =
serde_json::from_str(json).map_err(|e| format!("JSON parse error: {}", e))?;
let data = root["data"].as_array().ok_or("missing 'data' array")?;
Ok(data.iter().map(paper_from).collect())
}
fn paper_from(item: &serde_json::Value) -> Paper {
let authors: Vec<String> = item["authors"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|a| a["name"].as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let doi = item["externalIds"]["DOI"].as_str().map(|s| s.to_string());
let pdf_url = item["openAccessPdf"]["url"]
.as_str()
.filter(|s| !s.is_empty())
.map(|s| {
if s.contains("arxiv.org/abs/") {
s.replace("/abs/", "/pdf/")
} else {
s.to_string()
}
});
let fields: Vec<String> = item["fieldsOfStudy"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let citations = item["citationCount"].as_u64().map(|n| n as u32);
Paper {
id: item["paperId"].as_str().unwrap_or("").to_string(),
title: item["title"].as_str().unwrap_or("").to_string(),
authors,
abstract_text: item["abstract"].as_str().map(|s| s.to_string()),
year: item["year"].as_u64().map(|y| y as u16),
doi,
url: item["url"].as_str().map(|s| s.to_string()),
pdf_url,
venue: item["venue"].as_str().map(|s| s.to_string()),
citations,
fields,
open_access: Some(item["openAccessPdf"].is_object() && !item["openAccessPdf"].is_null()),
source: "semantic".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
const FIXTURE: &str = include_str!("../../tests/fixtures/semantic_search.json");
#[test]
fn parse_returns_ok() {
let result = parse_search_response(FIXTURE);
assert!(result.is_ok());
}
#[test]
fn parse_papers_not_empty() {
let papers = parse_search_response(FIXTURE).unwrap();
assert!(!papers.is_empty());
}
#[test]
fn parse_titles_not_empty() {
let papers = parse_search_response(FIXTURE).unwrap();
for p in &papers {
assert!(!p.title.is_empty(), "paper {} has empty title", p.id);
}
}
#[test]
fn parse_source_is_semantic() {
let papers = parse_search_response(FIXTURE).unwrap();
for p in &papers {
assert_eq!(p.source, "semantic");
}
}
#[test]
fn parse_citations_present() {
let papers = parse_search_response(FIXTURE).unwrap();
for p in &papers {
assert!(p.citations.is_some(), "paper {} missing citations", p.id);
assert!(p.citations.unwrap() > 0);
}
}
#[test]
fn parse_pdf_url_from_open_access() {
let papers = parse_search_response(FIXTURE).unwrap();
for p in &papers {
assert!(p.pdf_url.is_none(), "paper {} should have no pdf_url", p.id);
}
}
#[test]
fn parse_doi_from_external_ids() {
let papers = parse_search_response(FIXTURE).unwrap();
let first = &papers[0];
assert_eq!(first.doi.as_deref(), Some("10.1016/J.NEUCOM.2021.03.091"));
}
#[test]
fn parse_empty_data_returns_empty_list() {
let papers = parse_search_response(r#"{"data": []}"#).unwrap();
assert!(papers.is_empty());
}
#[test]
fn search_returns_papers() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_body(FIXTURE)
.create();
let papers = search(
&server.url(),
&crate::sources::SearchQuery::simple("test", 3),
)
.unwrap();
assert!(!papers.is_empty());
mock.assert();
}
#[test]
fn search_request_path_contains_paper_search() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", mockito::Matcher::Regex("paper/search".to_string()))
.with_status(200)
.with_body(FIXTURE)
.create();
let _ = search(
&server.url(),
&crate::sources::SearchQuery::simple("test", 3),
);
mock.assert();
}
#[test]
fn search_request_contains_query_param() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", mockito::Matcher::Regex("query=test".to_string()))
.with_status(200)
.with_body(FIXTURE)
.create();
let _ = search(
&server.url(),
&crate::sources::SearchQuery::simple("test", 3),
);
mock.assert();
}
#[test]
fn search_request_contains_limit() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", mockito::Matcher::Regex("limit=3".to_string()))
.with_status(200)
.with_body(FIXTURE)
.create();
let _ = search(
&server.url(),
&crate::sources::SearchQuery::simple("test", 3),
);
mock.assert();
}
#[test]
#[serial]
fn search_sends_api_key_header_when_set() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", mockito::Matcher::Any)
.match_header("x-api-key", "test-key-123")
.with_status(200)
.with_body(FIXTURE)
.create();
unsafe { std::env::set_var("SEMANTIC_SCHOLAR_API_KEY", "test-key-123") };
let _ = search(
&server.url(),
&crate::sources::SearchQuery::simple("test", 3),
);
unsafe { std::env::remove_var("SEMANTIC_SCHOLAR_API_KEY") };
mock.assert();
}
#[test]
#[serial]
fn search_works_without_api_key() {
unsafe { std::env::remove_var("SEMANTIC_SCHOLAR_API_KEY") };
let mut server = mockito::Server::new();
let mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_body(FIXTURE)
.create();
let result = search(
&server.url(),
&crate::sources::SearchQuery::simple("test", 3),
);
assert!(result.is_ok());
mock.assert();
}
#[test]
fn parse_retry_after_seconds() {
let cap = Duration::from_secs(30);
fn headers_with_retry_after(value: &str) -> ureq::http::HeaderMap {
let mut h = ureq::http::HeaderMap::new();
h.insert(
ureq::http::header::HeaderName::from_static("retry-after"),
ureq::http::HeaderValue::from_str(value).unwrap(),
);
h
}
assert_eq!(
parse_retry_after(&headers_with_retry_after("5"), cap),
Some(Duration::from_secs(5))
);
assert_eq!(
parse_retry_after(&headers_with_retry_after("abc"), cap),
None
);
assert_eq!(parse_retry_after(&ureq::http::HeaderMap::new(), cap), None);
assert_eq!(
parse_retry_after(&headers_with_retry_after("9999"), cap),
Some(cap)
);
}
#[test]
fn backoff_delay_grows_exponentially() {
let cfg = BackoffConfig {
base: Duration::from_millis(100),
max: Duration::from_secs(5),
max_retries: 5,
};
let d0 = backoff_delay(0, &cfg);
let d2 = backoff_delay(2, &cfg);
assert!(d0 < d2, "delay should grow with attempt");
assert!(
d2 <= cfg.max + cfg.max / 10,
"delay should respect max (with up-to-10% jitter)"
);
}
#[test]
fn backoff_delay_does_not_overflow() {
let cfg = BackoffConfig {
base: Duration::from_secs(1),
max: Duration::from_secs(30),
max_retries: 5,
};
let d = backoff_delay(20, &cfg);
assert!(d <= cfg.max + cfg.max / 10);
}
#[test]
fn throttle_with_sleeps_when_called_back_to_back() {
let state: Mutex<Option<Instant>> = Mutex::new(None);
let interval = Duration::from_millis(80);
let t0 = Instant::now();
throttle_with(&state, interval);
throttle_with(&state, interval);
let elapsed = t0.elapsed();
assert!(
elapsed >= interval,
"second call should wait at least one interval (got {:?})",
elapsed
);
}
#[test]
fn throttle_with_does_not_sleep_after_long_gap() {
let past = Instant::now() - Duration::from_secs(3600);
let state: Mutex<Option<Instant>> = Mutex::new(Some(past));
let interval = Duration::from_millis(100);
let t0 = Instant::now();
throttle_with(&state, interval);
let elapsed = t0.elapsed();
assert!(
elapsed < Duration::from_millis(20),
"should return immediately after long gap (got {:?})",
elapsed
);
}
#[test]
#[serial]
fn request_sends_user_agent_header() {
unsafe { std::env::remove_var("SEMANTIC_SCHOLAR_API_KEY") };
let mut server = mockito::Server::new();
let mock = server
.mock("GET", mockito::Matcher::Any)
.match_header(
"user-agent",
mockito::Matcher::Regex(r"^fastpaper-cli/\d+\.\d+\.\d+ \(\+https://".to_string()),
)
.with_status(200)
.with_body(FIXTURE)
.create();
let _ = search(
&server.url(),
&crate::sources::SearchQuery::simple("test", 3),
);
mock.assert();
}
#[test]
#[serial]
fn search_returns_err_on_rate_limit_exhausted() {
unsafe { std::env::remove_var("SEMANTIC_SCHOLAR_API_KEY") };
let mut server = mockito::Server::new();
let _m = server
.mock("GET", mockito::Matcher::Any)
.with_status(429)
.expect_at_least(4)
.create();
let cfg = BackoffConfig {
base: Duration::from_millis(10),
max: Duration::from_millis(50),
max_retries: 3,
};
let result = search_with_cfg_for_test(&server.url(), "test", 3, &cfg);
assert!(
matches!(result, Err(ref e) if e.contains("rate limited")),
"expected Err on rate-limit exhausted (consistent with other sources), got {:?}",
result
);
}
#[test]
#[serial]
fn rate_limit_then_success_respects_retry_after() {
unsafe { std::env::remove_var("SEMANTIC_SCHOLAR_API_KEY") };
let mut server = mockito::Server::new();
let m1 = server
.mock("GET", mockito::Matcher::Any)
.with_status(429)
.with_header("retry-after", "1")
.expect(1)
.create();
let m2 = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_body(FIXTURE)
.expect(1)
.create();
let t0 = Instant::now();
let result = search(
&server.url(),
&crate::sources::SearchQuery::simple("test", 3),
);
let elapsed = t0.elapsed();
assert!(result.is_ok(), "expected Ok, got {:?}", result);
assert!(!result.unwrap().is_empty());
assert!(
elapsed >= Duration::from_secs(1) && elapsed < Duration::from_secs(3),
"expected ~1s wait (got {:?})",
elapsed
);
m1.assert();
m2.assert();
}
#[test]
#[serial]
fn get_by_id_returns_err_on_rate_limit_exhausted() {
unsafe { std::env::remove_var("SEMANTIC_SCHOLAR_API_KEY") };
let mut server = mockito::Server::new();
let _m = server
.mock("GET", mockito::Matcher::Any)
.with_status(429)
.expect_at_least(4)
.create();
let cfg = BackoffConfig {
base: Duration::from_millis(10),
max: Duration::from_millis(50),
max_retries: 3,
};
let result = get_by_id_with_cfg_for_test(&server.url(), "abc123", &cfg);
assert!(
matches!(result, Err(ref e) if e.contains("rate limited")),
"expected Err on rate-limit exhausted, got {:?}",
result
);
}
#[test]
#[serial]
fn forbidden_with_key_strips_key_and_retries() {
unsafe { std::env::set_var("SEMANTIC_SCHOLAR_API_KEY", "bad-key") };
let mut server = mockito::Server::new();
let m_403 = server
.mock("GET", mockito::Matcher::Any)
.match_header("x-api-key", "bad-key")
.with_status(403)
.expect(1)
.create();
let m_200 = server
.mock("GET", mockito::Matcher::Any)
.match_header("x-api-key", mockito::Matcher::Missing)
.with_status(200)
.with_body(FIXTURE)
.expect(1)
.create();
let result = search(
&server.url(),
&crate::sources::SearchQuery::simple("test", 3),
);
unsafe { std::env::remove_var("SEMANTIC_SCHOLAR_API_KEY") };
assert!(
result.is_ok(),
"expected Ok after key-strip retry, got {:?}",
result
);
assert!(!result.unwrap().is_empty());
m_403.assert();
m_200.assert();
}
}
#[cfg(test)]
mod query_tests {
use super::*;
use crate::sources::{SearchQuery, SortField, SortOrder};
fn url(q: &SearchQuery) -> String {
build_search_url("https://api.semanticscholar.org", q).unwrap()
}
#[test]
fn plain_query_uses_the_relevance_endpoint() {
let u = url(&SearchQuery::simple("attention", 10));
assert!(u.contains("/graph/v1/paper/search?"), "got: {}", u);
assert!(u.contains("query=attention"), "got: {}", u);
assert!(u.contains("limit=10"), "got: {}", u);
}
#[test]
fn offset_is_passed_through() {
let mut q = SearchQuery::simple("attention", 10);
q.offset = 40;
assert!(url(&q).contains("offset=40"));
}
#[test]
fn offset_beyond_the_api_limit_is_rejected() {
let mut q = SearchQuery::simple("attention", 10);
q.offset = 1001;
let err = build_search_url("https://api.semanticscholar.org", &q).unwrap_err();
assert!(err.contains("1000"), "got: {}", err);
}
#[test]
fn year_is_passed_through() {
let mut q = SearchQuery::simple("attention", 10);
q.year = Some(2017);
assert!(url(&q).contains("year=2017"), "got: {}", url(&q));
}
#[test]
fn dates_become_a_publication_date_range() {
let mut q = SearchQuery::simple("attention", 10);
q.after = Some("2024-01-01".into());
q.before = Some("2024-03-31".into());
assert!(
url(&q).contains("publicationDateOrYear=2024-01-01:2024-03-31"),
"got: {}",
url(&q)
);
}
#[test]
fn after_alone_leaves_the_upper_bound_open() {
let mut q = SearchQuery::simple("attention", 10);
q.after = Some("2024-01-01".into());
assert!(
url(&q).contains("publicationDateOrYear=2024-01-01:"),
"got: {}",
url(&q)
);
}
#[test]
fn field_becomes_fields_of_study() {
let mut q = SearchQuery::simple("attention", 10);
q.field = Some("Computer Science".into());
assert!(
url(&q).contains("fieldsOfStudy=Computer"),
"got: {}",
url(&q)
);
}
#[test]
fn open_access_adds_a_bare_flag() {
let mut q = SearchQuery::simple("attention", 10);
q.open_access = true;
let u = url(&q);
assert!(u.contains("openAccessPdf"), "got: {}", u);
assert!(
!u.contains("openAccessPdf=true"),
"flag takes no value: {}",
u
);
}
#[test]
fn bulk_results_are_truncated_to_the_limit() {
let body = format!(
r#"{{"total":50,"data":[{}]}}"#,
(0..50)
.map(|i| format!(r#"{{"paperId":"p{}","title":"T{}"}}"#, i, i))
.collect::<Vec<_>>()
.join(",")
);
let mut server = mockito::Server::new();
server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_body(body)
.create();
let mut q = SearchQuery::simple("x", 3);
q.sort = Some(SortField::Citations);
let papers = search(&server.url(), &q).unwrap();
assert_eq!(papers.len(), 3, "should honour -n on the bulk endpoint");
}
#[test]
fn sorting_switches_to_the_bulk_endpoint() {
let mut q = SearchQuery::simple("attention", 10);
q.sort = Some(SortField::Citations);
let u = url(&q);
assert!(u.contains("/paper/search/bulk?"), "got: {}", u);
assert!(u.contains("sort=citationCount:desc"), "got: {}", u);
}
#[test]
fn sort_by_date_uses_publication_date() {
let mut q = SearchQuery::simple("attention", 10);
q.sort = Some(SortField::Date);
q.order = SortOrder::Asc;
assert!(
url(&q).contains("sort=publicationDate:asc"),
"got: {}",
url(&q)
);
}
#[test]
fn sort_by_relevance_stays_on_the_relevance_endpoint() {
let mut q = SearchQuery::simple("attention", 10);
q.sort = Some(SortField::Relevance);
let u = url(&q);
assert!(u.contains("/paper/search?"), "got: {}", u);
assert!(
!u.contains("sort="),
"relevance is the default ranking: {}",
u
);
}
#[test]
fn offset_with_sorting_is_rejected() {
let mut q = SearchQuery::simple("attention", 10);
q.sort = Some(SortField::Citations);
q.offset = 10;
let err = build_search_url("https://api.semanticscholar.org", &q).unwrap_err();
assert!(err.contains("--offset"), "got: {}", err);
}
#[test]
fn author_is_rejected_with_an_alternative() {
let mut q = SearchQuery::simple("attention", 10);
q.author = Some("Vaswani".into());
let err = build_search_url("https://api.semanticscholar.org", &q).unwrap_err();
assert!(err.contains("--author"), "got: {}", err);
}
}
#[cfg(test)]
mod edge_tests {
use super::*;
use crate::sources::Direction;
const REFS: &str = include_str!("../../tests/fixtures/semantic_references.json");
const CITES: &str = include_str!("../../tests/fixtures/semantic_citations.json");
#[test]
fn outgoing_edges_unwrap_cited_papers() {
let papers = parse_edge_response(REFS, Direction::Outgoing).unwrap();
assert_eq!(papers.len(), 3);
assert!(papers.iter().all(|p| !p.title.is_empty()));
assert!(papers.iter().all(|p| p.source == "semantic"));
}
#[test]
fn incoming_edges_unwrap_citing_papers() {
let papers = parse_edge_response(CITES, Direction::Incoming).unwrap();
assert_eq!(papers.len(), 3);
assert!(papers.iter().all(|p| !p.title.is_empty()));
}
#[test]
fn reading_the_wrong_side_yields_no_papers() {
assert!(
parse_edge_response(REFS, Direction::Incoming)
.unwrap()
.is_empty()
);
}
#[test]
fn edge_papers_carry_the_same_fields_as_search_results() {
let p = &parse_edge_response(CITES, Direction::Incoming).unwrap()[0];
assert!(p.year.is_some(), "year missing");
assert!(p.citations.is_some(), "citationCount missing");
assert!(!p.authors.is_empty(), "authors missing");
}
}