use crate::Result;
use crate::arxiv::Paper;
use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue, USER_AGENT};
use serde::{Deserialize, Serialize};
use std::time::Duration;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Repository {
pub forge: String,
pub url: String,
pub owner: String,
pub name: String,
pub description: Option<String>,
pub primary_language: Option<String>,
pub stars: Option<u64>,
pub forks: Option<u64>,
pub license: Option<String>,
pub archived: Option<bool>,
pub default_branch: Option<String>,
pub last_commit_date: Option<String>,
pub source_provider: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RepositoryRef {
pub forge: String,
pub owner: String,
pub name: String,
pub url: String,
}
#[derive(Debug, Clone)]
pub struct RepoSearchQuery {
pub terms: Vec<String>,
pub limit: usize,
}
pub fn search_terms(paper: &Paper) -> Vec<String> {
let mut terms = vec![paper.title.clone()];
if let Some(arxiv_id) = paper.arxiv_id.as_deref() {
terms.push(trim_arxiv_version(arxiv_id).to_string());
}
if let Some(doi) = paper.doi.as_deref() {
terms.push(doi.to_string());
}
terms.extend(paper.extracted_methods.iter().cloned());
dedup_strings(terms)
}
pub async fn search_for_paper(paper: &Paper, limit: usize) -> Result<Vec<Repository>> {
let query = RepoSearchQuery {
terms: search_terms(paper).into_iter().take(3).collect(),
limit,
};
let mut repos = Vec::new();
repos.extend(github_search(&query).await.unwrap_or_default());
repos.extend(gitlab_search(&query).await.unwrap_or_default());
repos.extend(
forgejo_search("codeberg", "https://codeberg.org/api/v1", &query)
.await
.unwrap_or_default(),
);
Ok(dedup_repositories(repos))
}
pub fn github_search_url(term: &str, limit: usize) -> String {
format!(
"https://api.github.com/search/repositories?q={}&per_page={}",
urlencoding::encode(term),
limit
)
}
pub fn gitlab_search_url(term: &str, limit: usize) -> String {
format!(
"https://gitlab.com/api/v4/projects?search={}&per_page={}",
urlencoding::encode(term),
limit
)
}
pub fn forgejo_search_url(base_url: &str, term: &str, limit: usize) -> String {
format!(
"{}/repos/search?q={}&limit={}",
base_url.trim_end_matches('/'),
urlencoding::encode(term),
limit
)
}
pub fn github_inspect_url(owner: &str, name: &str) -> String {
format!("https://api.github.com/repos/{}/{}", owner, name)
}
pub fn gitlab_inspect_url(owner: &str, name: &str) -> String {
format!(
"https://gitlab.com/api/v4/projects/{}",
urlencoding::encode(&format!("{owner}/{name}"))
)
}
pub fn forgejo_inspect_url(base_url: &str, owner: &str, name: &str) -> String {
format!(
"{}/repos/{}/{}",
base_url.trim_end_matches('/'),
owner,
name
)
}
pub async fn github_search(query: &RepoSearchQuery) -> Result<Vec<Repository>> {
let client = client(Some("GITHUB_TOKEN"));
let mut repos = Vec::new();
for term in &query.terms {
let response = client
.get(github_search_url(term, query.limit))
.send()
.await?;
if response.status() == reqwest::StatusCode::FORBIDDEN
|| response.status() == reqwest::StatusCode::UNAUTHORIZED
{
continue;
}
repos.extend(parse_github_search(
&response.error_for_status()?.text().await?,
)?);
}
Ok(dedup_repositories(repos))
}
pub async fn gitlab_search(query: &RepoSearchQuery) -> Result<Vec<Repository>> {
let client = client(Some("GITLAB_TOKEN"));
let mut repos = Vec::new();
for term in &query.terms {
let response = client
.get(gitlab_search_url(term, query.limit))
.send()
.await?;
if response.status() == reqwest::StatusCode::FORBIDDEN
|| response.status() == reqwest::StatusCode::UNAUTHORIZED
{
continue;
}
repos.extend(parse_gitlab_search(
&response.error_for_status()?.text().await?,
)?);
}
Ok(dedup_repositories(repos))
}
pub async fn forgejo_search(
provider: &str,
base_url: &str,
query: &RepoSearchQuery,
) -> Result<Vec<Repository>> {
let client = client(None);
let mut repos = Vec::new();
for term in &query.terms {
let response = client
.get(forgejo_search_url(base_url, term, query.limit))
.send()
.await?;
if response.status() == reqwest::StatusCode::FORBIDDEN
|| response.status() == reqwest::StatusCode::UNAUTHORIZED
{
continue;
}
repos.extend(parse_forgejo_search(
provider,
&response.error_for_status()?.text().await?,
)?);
}
Ok(dedup_repositories(repos))
}
pub async fn inspect(repo: &RepositoryRef) -> Result<Option<Repository>> {
let client = client(match repo.forge.as_str() {
"github" => Some("GITHUB_TOKEN"),
"gitlab" => Some("GITLAB_TOKEN"),
_ => None,
});
let url = match repo.forge.as_str() {
"github" => github_inspect_url(&repo.owner, &repo.name),
"gitlab" => gitlab_inspect_url(&repo.owner, &repo.name),
"codeberg" => forgejo_inspect_url("https://codeberg.org/api/v1", &repo.owner, &repo.name),
_ => return Ok(None),
};
let response = client.get(url).send().await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
let text = response.error_for_status()?.text().await?;
Ok(Some(match repo.forge.as_str() {
"github" => parse_github_repo(&text)?,
"gitlab" => parse_gitlab_repo(&text)?,
_ => parse_forgejo_repo(&repo.forge, &text)?,
}))
}
pub fn parse_github_search(json: &str) -> Result<Vec<Repository>> {
#[derive(Deserialize)]
struct Search {
items: Vec<GithubRepo>,
}
Ok(serde_json::from_str::<Search>(json)?
.items
.into_iter()
.map(Into::into)
.collect())
}
pub fn parse_github_repo(json: &str) -> Result<Repository> {
Ok(serde_json::from_str::<GithubRepo>(json)?.into())
}
pub fn parse_gitlab_search(json: &str) -> Result<Vec<Repository>> {
Ok(serde_json::from_str::<Vec<GitlabRepo>>(json)?
.into_iter()
.map(Into::into)
.collect())
}
pub fn parse_gitlab_repo(json: &str) -> Result<Repository> {
Ok(serde_json::from_str::<GitlabRepo>(json)?.into())
}
pub fn parse_forgejo_search(provider: &str, json: &str) -> Result<Vec<Repository>> {
#[derive(Deserialize)]
struct Search {
data: Vec<ForgejoRepo>,
}
Ok(serde_json::from_str::<Search>(json)?
.data
.into_iter()
.map(|r| r.into_repo(provider))
.collect())
}
pub fn parse_forgejo_repo(provider: &str, json: &str) -> Result<Repository> {
Ok(serde_json::from_str::<ForgejoRepo>(json)?.into_repo(provider))
}
fn client(token_env: Option<&str>) -> reqwest::Client {
let mut headers = HeaderMap::new();
headers.insert(USER_AGENT, HeaderValue::from_static("paper-gap"));
if let Some(token_env) = token_env.and_then(|name| std::env::var(name).ok()) {
if let Ok(value) = HeaderValue::from_str(&format!("Bearer {token_env}")) {
headers.insert(AUTHORIZATION, value);
}
}
reqwest::Client::builder()
.default_headers(headers)
.timeout(Duration::from_secs(8))
.build()
.expect("valid reqwest client")
}
#[derive(Deserialize)]
struct GithubRepo {
html_url: String,
name: String,
owner: GithubOwner,
description: Option<String>,
language: Option<String>,
stargazers_count: Option<u64>,
forks_count: Option<u64>,
license: Option<GithubLicense>,
archived: Option<bool>,
default_branch: Option<String>,
pushed_at: Option<String>,
}
#[derive(Deserialize)]
struct GithubOwner {
login: String,
}
#[derive(Deserialize)]
struct GithubLicense {
spdx_id: Option<String>,
name: Option<String>,
}
impl From<GithubRepo> for Repository {
fn from(repo: GithubRepo) -> Self {
Repository {
forge: "github".into(),
url: repo.html_url,
owner: repo.owner.login,
name: repo.name,
description: repo.description,
primary_language: repo.language,
stars: repo.stargazers_count,
forks: repo.forks_count,
license: repo.license.and_then(|l| l.spdx_id.or(l.name)),
archived: repo.archived,
default_branch: repo.default_branch,
last_commit_date: repo.pushed_at,
source_provider: "github".into(),
}
}
}
#[derive(Deserialize)]
struct GitlabRepo {
web_url: String,
path: String,
path_with_namespace: String,
description: Option<String>,
star_count: Option<u64>,
forks_count: Option<u64>,
archived: Option<bool>,
default_branch: Option<String>,
last_activity_at: Option<String>,
}
impl From<GitlabRepo> for Repository {
fn from(repo: GitlabRepo) -> Self {
let owner = repo
.path_with_namespace
.rsplit_once('/')
.map_or("", |(owner, _)| owner)
.to_string();
Repository {
forge: "gitlab".into(),
url: repo.web_url,
owner,
name: repo.path,
description: repo.description,
primary_language: None,
stars: repo.star_count,
forks: repo.forks_count,
license: None,
archived: repo.archived,
default_branch: repo.default_branch,
last_commit_date: repo.last_activity_at,
source_provider: "gitlab".into(),
}
}
}
#[derive(Deserialize)]
struct ForgejoRepo {
html_url: String,
name: String,
owner: ForgejoOwner,
description: Option<String>,
language: Option<String>,
stars_count: Option<u64>,
forks_count: Option<u64>,
archived: Option<bool>,
default_branch: Option<String>,
updated_at: Option<String>,
}
#[derive(Deserialize)]
struct ForgejoOwner {
login: String,
}
impl ForgejoRepo {
fn into_repo(self, provider: &str) -> Repository {
Repository {
forge: provider.into(),
url: self.html_url,
owner: self.owner.login,
name: self.name,
description: self.description,
primary_language: self.language,
stars: self.stars_count,
forks: self.forks_count,
license: None,
archived: self.archived,
default_branch: self.default_branch,
last_commit_date: self.updated_at,
source_provider: provider.into(),
}
}
}
fn dedup_repositories(repos: Vec<Repository>) -> Vec<Repository> {
let mut out = Vec::new();
for repo in repos {
if !out
.iter()
.any(|existing: &Repository| existing.url == repo.url)
{
out.push(repo);
}
}
out
}
fn dedup_strings(values: Vec<String>) -> Vec<String> {
let mut out = Vec::new();
for value in values.into_iter().filter(|s| !s.trim().is_empty()) {
if !out.iter().any(|existing| existing == &value) {
out.push(value);
}
}
out
}
fn trim_arxiv_version(arxiv_id: &str) -> &str {
arxiv_id.rsplit_once('v').map_or(arxiv_id, |(id, version)| {
if version.chars().all(|c| c.is_ascii_digit()) {
id
} else {
arxiv_id
}
})
}