use async_trait::async_trait;
use serde::Deserialize;
use crate::domain::paper::Paper;
use crate::error::{ResearchError, Result};
use crate::ports::paper_source::PaperSource;
pub(crate) fn percent_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
b' ' => out.push('+'),
_ => {
out.push('%');
out.push(
char::from_digit((b >> 4) as u32, 16)
.unwrap()
.to_ascii_uppercase(),
);
out.push(
char::from_digit((b & 0xf) as u32, 16)
.unwrap()
.to_ascii_uppercase(),
);
}
}
}
out
}
pub struct SemanticScholarSource {
client: reqwest::Client,
}
impl SemanticScholarSource {
pub fn new() -> Self {
Self {
client: reqwest::Client::new(),
}
}
}
impl Default for SemanticScholarSource {
fn default() -> Self {
Self::new()
}
}
#[derive(Deserialize)]
struct S2Response {
data: Option<Vec<S2Paper>>,
}
#[derive(Deserialize)]
struct S2Paper {
#[serde(rename = "paperId")]
paper_id: Option<String>,
title: Option<String>,
authors: Option<Vec<S2Author>>,
#[serde(rename = "abstract")]
abstract_text: Option<String>,
year: Option<u32>,
venue: Option<String>,
#[serde(rename = "externalIds")]
external_ids: Option<S2ExternalIds>,
}
#[derive(Deserialize)]
struct S2Author {
name: Option<String>,
}
#[derive(Deserialize)]
struct S2ExternalIds {
#[serde(rename = "ArXiv")]
arxiv: Option<String>,
#[serde(rename = "DOI")]
doi: Option<String>,
}
#[derive(Debug, Clone)]
pub struct CitationIntent {
pub doi: Option<String>,
pub s2_id: Option<String>,
pub intents: Vec<String>,
pub influential: bool,
}
impl CitationIntent {
pub fn label(&self) -> String {
let mut parts = self.intents.clone();
if self.influential {
parts.push("influential".to_string());
}
parts.join("+")
}
}
#[derive(Deserialize)]
struct S2CitationsResponse {
data: Option<Vec<S2CitationEdge>>,
}
#[derive(Deserialize)]
struct S2CitationEdge {
#[serde(default)]
intents: Option<Vec<String>>,
#[serde(rename = "isInfluential", default)]
is_influential: Option<bool>,
#[serde(rename = "citedPaper", default)]
cited_paper: Option<S2Paper>,
#[serde(rename = "citingPaper", default)]
citing_paper: Option<S2Paper>,
}
impl S2CitationEdge {
fn into_intent(self) -> Option<CitationIntent> {
let other = self.cited_paper.or(self.citing_paper)?;
Some(CitationIntent {
doi: other.external_ids.as_ref().and_then(|e| e.doi.clone()),
s2_id: other.paper_id,
intents: self.intents.unwrap_or_default(),
influential: self.is_influential.unwrap_or(false),
})
}
}
impl SemanticScholarSource {
pub async fn citation_intents(
&self,
paper: &Paper,
direction: &str,
) -> Result<Vec<CitationIntent>> {
let key = match (&paper.s2_id, &paper.doi) {
(Some(id), _) => id.clone(),
(None, Some(doi)) => format!("DOI:{doi}"),
(None, None) => {
return Err(ResearchError::Source(
"paper has no Semantic Scholar id or DOI; cannot resolve citation intents"
.into(),
));
}
};
let endpoint = match direction {
"references" | "citations" => direction,
other => {
return Err(ResearchError::Source(format!(
"unknown citation direction '{other}'"
)));
}
};
let url = format!(
"https://api.semanticscholar.org/graph/v1/paper/{}/{endpoint}?fields=intents,isInfluential,externalIds&limit=1000",
percent_encode(&key),
);
let resp = self
.client
.get(&url)
.header("User-Agent", "research-agent/0.1")
.send()
.await
.map_err(|e| ResearchError::Source(format!("S2 request failed: {e}")))?;
let status = resp.status();
if !status.is_success() {
if status.as_u16() == 429 {
return Err(ResearchError::Source(
"S2 API rate limit hit (HTTP 429); retry later or configure an API key".into(),
));
}
return Err(ResearchError::Source(format!(
"S2 API returned HTTP {status}"
)));
}
let parsed: S2CitationsResponse = resp
.json()
.await
.map_err(|e| ResearchError::Source(format!("S2 JSON parse failed: {e}")))?;
Ok(parsed
.data
.unwrap_or_default()
.into_iter()
.filter_map(|e| e.into_intent())
.collect())
}
}
#[async_trait]
impl PaperSource for SemanticScholarSource {
async fn fetch_papers(&self, query: &str, limit: usize) -> Result<Vec<Paper>> {
let encoded = percent_encode(query);
let url = format!(
"https://api.semanticscholar.org/graph/v1/paper/search?query={}&limit={}&fields=title,authors,abstract,year,venue,externalIds",
encoded, limit
);
let resp = self
.client
.get(&url)
.header("User-Agent", "research-agent/0.1")
.send()
.await
.map_err(|e| ResearchError::Source(format!("S2 request failed: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
return Err(ResearchError::Source(format!(
"S2 API returned HTTP {status}"
)));
}
let s2: S2Response = resp
.json()
.await
.map_err(|e| ResearchError::Source(format!("S2 JSON parse failed: {e}")))?;
let papers = s2
.data
.unwrap_or_default()
.into_iter()
.map(|s| {
let mut paper = Paper::new(s.title.unwrap_or_default());
paper.s2_id = s.paper_id;
paper.abstract_text = s.abstract_text.unwrap_or_default();
paper.authors = s
.authors
.unwrap_or_default()
.into_iter()
.filter_map(|a| a.name)
.collect();
paper.year = s.year;
paper.venue = s.venue;
if let Some(ext) = s.external_ids {
paper.arxiv_id = ext.arxiv;
paper.doi = ext.doi;
}
paper
})
.collect();
Ok(papers)
}
fn name(&self) -> &str {
"semantic_scholar"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_s2_json() {
let json = r#"{"data":[{"paperId":"abc123","title":"Attention Is All You Need","authors":[{"name":"Vaswani"}],"abstract":"Transformer architecture.","year":2017,"venue":"NeurIPS","externalIds":{"ArXiv":"1706.03762","DOI":"10.1145/123"}}]}"#;
let s2: S2Response = serde_json::from_str(json).unwrap();
let items = s2.data.unwrap_or_default();
assert_eq!(items.len(), 1);
assert_eq!(items[0].title.as_deref(), Some("Attention Is All You Need"));
assert_eq!(items[0].paper_id.as_deref(), Some("abc123"));
assert_eq!(items[0].year, Some(2017));
assert_eq!(
items[0]
.external_ids
.as_ref()
.and_then(|e| e.arxiv.as_deref()),
Some("1706.03762")
);
}
#[test]
fn parse_s2_empty() {
let json = r#"{"data":[]}"#;
let s2: S2Response = serde_json::from_str(json).unwrap();
assert!(s2.data.unwrap_or_default().is_empty());
}
#[test]
fn parse_s2_null_data() {
let json = r#"{"total":0}"#;
let s2: S2Response = serde_json::from_str(json).unwrap();
assert!(s2.data.unwrap_or_default().is_empty());
}
#[test]
fn source_name() {
let source = SemanticScholarSource::new();
assert_eq!(source.name(), "semantic_scholar");
}
#[test]
fn parses_both_citation_edge_shapes() {
let refs = r#"{"data":[{"isInfluential":true,"intents":["methodology"],"citedPaper":{"paperId":"p1","externalIds":{"DOI":"10.1/a"}}}]}"#;
let parsed: S2CitationsResponse = serde_json::from_str(refs).unwrap();
let edge = parsed.data.unwrap().pop().unwrap().into_intent().unwrap();
assert_eq!(edge.doi.as_deref(), Some("10.1/a"));
assert_eq!(edge.s2_id.as_deref(), Some("p1"));
assert_eq!(edge.label(), "methodology+influential");
let cites = r#"{"data":[{"isInfluential":false,"intents":[],"citingPaper":{"paperId":"p2","externalIds":{"DOI":"10.1/b"}}}]}"#;
let parsed: S2CitationsResponse = serde_json::from_str(cites).unwrap();
let edge = parsed.data.unwrap().pop().unwrap().into_intent().unwrap();
assert_eq!(edge.doi.as_deref(), Some("10.1/b"));
assert_eq!(edge.label(), "");
}
#[test]
fn edge_without_paper_is_skipped() {
let json = r#"{"data":[{"isInfluential":false,"intents":["background"]}]}"#;
let parsed: S2CitationsResponse = serde_json::from_str(json).unwrap();
assert!(parsed.data.unwrap().pop().unwrap().into_intent().is_none());
}
#[tokio::test]
async fn citation_intents_rejects_unknown_direction() {
let mut paper = Paper::new("x".into());
paper.doi = Some("10.1/a".into());
let err = SemanticScholarSource::new()
.citation_intents(&paper, "sideways")
.await
.unwrap_err();
assert!(err.to_string().contains("unknown citation direction"));
}
#[tokio::test]
async fn citation_intents_requires_identity() {
let paper = Paper::new("no ids".into());
let err = SemanticScholarSource::new()
.citation_intents(&paper, "references")
.await
.unwrap_err();
assert!(err.to_string().contains("no Semantic Scholar id or DOI"));
}
}