use crate::Result;
use serde::{Deserialize, Serialize};
use std::time::Duration;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProviderInfo {
pub id: &'static str,
pub display_name: &'static str,
pub kind: ProviderKind,
pub status: ProviderStatus,
pub auth: AuthKind,
pub base_url: &'static str,
pub notes: &'static str,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ProviderKind {
Paper,
Code,
Repo,
Metadata,
Both,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ProviderStatus {
Enabled,
Available,
Unavailable,
Planned,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AuthKind {
None,
Optional,
Required,
Paid,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceFilter {
All,
Papers,
Repos,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiscoverKind {
Papers,
Repos,
All,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DiscoveredSource {
#[serde(default = "default_discovery_backend")]
pub discovery_backend: String,
pub name: String,
pub url: String,
pub kind: String,
pub source_type: String,
pub auth: String,
pub api_available: String,
pub docs_url: Option<String>,
pub coverage_notes: String,
pub integration_difficulty: String,
pub confidence: String,
pub evidence_links: Vec<String>,
pub evidence_snippets: Vec<String>,
}
pub fn builtin() -> Vec<ProviderInfo> {
vec![
ProviderInfo {
id: "arxiv",
display_name: "arXiv",
kind: ProviderKind::Paper,
status: ProviderStatus::Enabled,
auth: AuthKind::None,
base_url: "https://export.arxiv.org/api/query",
notes: "MVP paper provider",
},
ProviderInfo {
id: "openalex",
display_name: "OpenAlex",
kind: ProviderKind::Paper,
status: ProviderStatus::Enabled,
auth: AuthKind::None,
base_url: "https://api.openalex.org/works",
notes: "Broad scholarly metadata provider; OPENALEX_EMAIL optional",
},
ProviderInfo {
id: "papers-with-code",
display_name: "Papers with Code",
kind: ProviderKind::Code,
status: ProviderStatus::Enabled,
auth: AuthKind::None,
base_url: "https://paperswithcode.com/api/v1",
notes: "MVP code-link signal",
},
ProviderInfo {
id: "github",
display_name: "GitHub",
kind: ProviderKind::Repo,
status: ProviderStatus::Enabled,
auth: AuthKind::Optional,
base_url: "https://api.github.com",
notes: "MVP repository search",
},
ProviderInfo {
id: "gitlab",
display_name: "GitLab",
kind: ProviderKind::Repo,
status: ProviderStatus::Enabled,
auth: AuthKind::Optional,
base_url: "https://gitlab.com/api/v4",
notes: "MVP repository search",
},
ProviderInfo {
id: "codeberg",
display_name: "Codeberg",
kind: ProviderKind::Repo,
status: ProviderStatus::Enabled,
auth: AuthKind::Optional,
base_url: "https://codeberg.org/api/v1",
notes: "Forgejo/Gitea default instance",
},
]
}
pub fn filter(providers: Vec<ProviderInfo>, filter: SourceFilter) -> Vec<ProviderInfo> {
providers
.into_iter()
.filter(|provider| match filter {
SourceFilter::All => true,
SourceFilter::Papers => matches!(
provider.kind,
ProviderKind::Paper | ProviderKind::Metadata | ProviderKind::Both
),
SourceFilter::Repos => matches!(provider.kind, ProviderKind::Repo),
})
.collect()
}
pub async fn health_check(url: &str) -> &'static str {
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
{
Ok(client) => client,
Err(_) => return "unreachable",
};
match client.get(url).send().await {
Ok(response) if response.status().is_success() || response.status().is_redirection() => {
"reachable"
}
Ok(_) => "unreachable",
Err(_) => "unreachable",
}
}
pub fn discovery_queries(kind: DiscoverKind) -> Vec<&'static str> {
match kind {
DiscoverKind::Papers => vec![
"scholarly metadata APIs",
"research paper APIs",
"preprint APIs",
"open-access paper indexes",
"domain-specific literature databases",
"conference open proceedings APIs",
],
DiscoverKind::Repos => vec![
"public code forges",
"Git hosting APIs",
"Forgejo Gitea instances",
"GitLab instances",
"institutional research code hosts",
"package registries research software",
],
DiscoverKind::All => {
let mut queries = discovery_queries(DiscoverKind::Papers);
queries.extend(discovery_queries(DiscoverKind::Repos));
queries
}
}
}
pub async fn discover(kind: DiscoverKind) -> Result<Vec<DiscoveredSource>> {
let client = reqwest::Client::builder()
.user_agent("paper-gap")
.timeout(Duration::from_secs(10))
.build()?;
let brave_key = std::env::var("BRAVE_SEARCH_API_KEY").ok();
let mut sources = Vec::new();
for query in discovery_queries(kind) {
let discovered = if let Some(key) = brave_key.as_deref() {
match discover_brave(&client, kind, query, key).await {
Ok(found) => found,
Err(error) => {
eprintln!(
"paper-gap: Brave discovery failed for {query}: {error}; falling back to DuckDuckGo"
);
discover_duckduckgo(&client, kind, query)
.await
.unwrap_or_else(|error| {
eprintln!(
"paper-gap: DuckDuckGo discovery failed for {query}: {error}"
);
Vec::new()
})
}
}
} else {
discover_duckduckgo(&client, kind, query)
.await
.unwrap_or_else(|error| {
eprintln!("paper-gap: DuckDuckGo discovery failed for {query}: {error}");
Vec::new()
})
};
sources.extend(discovered);
}
Ok(deduplicate_discovered_sources(sources))
}
async fn discover_duckduckgo(
client: &reqwest::Client,
kind: DiscoverKind,
query: &str,
) -> Result<Vec<DiscoveredSource>> {
let url = format!(
"https://duckduckgo.com/html/?q={}",
urlencoding::encode(query)
);
let body = client
.get(&url)
.send()
.await?
.error_for_status()?
.text()
.await?;
Ok(parse_duckduckgo_html(kind, query, &url, &body))
}
async fn discover_brave(
client: &reqwest::Client,
kind: DiscoverKind,
query: &str,
key: &str,
) -> Result<Vec<DiscoveredSource>> {
let url = format!(
"https://api.search.brave.com/res/v1/web/search?q={}&count=5",
urlencoding::encode(query)
);
let body = client
.get(&url)
.header("X-Subscription-Token", key)
.send()
.await?
.error_for_status()?
.text()
.await?;
parse_brave_json(kind, query, &url, &body)
}
pub fn parse_brave_json(
kind: DiscoverKind,
query: &str,
search_url: &str,
json: &str,
) -> Result<Vec<DiscoveredSource>> {
#[derive(Deserialize)]
struct Response {
web: Option<Web>,
}
#[derive(Deserialize)]
struct Web {
results: Vec<Item>,
}
#[derive(Deserialize)]
struct Item {
title: String,
url: String,
description: Option<String>,
}
let response: Response = serde_json::from_str(json)?;
Ok(response
.web
.into_iter()
.flat_map(|web| web.results)
.take(5)
.map(|item| {
discovered_source(
"brave_api",
kind,
query,
search_url,
item.title,
item.url,
item.description.unwrap_or_default(),
"medium",
)
})
.collect())
}
pub fn parse_duckduckgo_html(
kind: DiscoverKind,
query: &str,
search_url: &str,
html: &str,
) -> Vec<DiscoveredSource> {
html.lines()
.filter(|line| line.contains("result__a"))
.take(5)
.map(|line| {
let name = strip_html(line).trim().to_string();
let result_url = attribute(line, "href").unwrap_or_else(|| search_url.to_string());
discovered_source(
"duckduckgo_html",
kind,
query,
search_url,
if name.is_empty() { query.into() } else { name },
result_url,
strip_html(line),
"low",
)
})
.collect()
}
fn discovered_source(
backend: &str,
kind: DiscoverKind,
query: &str,
search_url: &str,
name: String,
url: String,
snippet: String,
confidence: &str,
) -> DiscoveredSource {
let lower = format!("{} {}", name, url).to_ascii_lowercase();
let docs_url = (lower.contains("docs") || lower.contains("api")).then(|| url.clone());
DiscoveredSource {
discovery_backend: backend.into(),
name,
url: url.clone(),
kind: match kind {
DiscoverKind::Papers => "papers",
DiscoverKind::Repos => "repos",
DiscoverKind::All => "both",
}
.into(),
source_type: classify(query).into(),
auth: "unknown".into(),
api_available: if docs_url.is_some() { "yes" } else { "unknown" }.into(),
docs_url,
coverage_notes: format!("Discovered from query: {query}"),
integration_difficulty: "unknown".into(),
confidence: confidence.into(),
evidence_links: vec![url, search_url.into()],
evidence_snippets: vec![snippet],
}
}
pub fn deduplicate_discovered_sources(sources: Vec<DiscoveredSource>) -> Vec<DiscoveredSource> {
let mut deduplicated = Vec::new();
for source in sources {
let domain = normalized_domain(&source.url);
if domain.is_empty()
|| !deduplicated
.iter()
.any(|existing: &DiscoveredSource| normalized_domain(&existing.url) == domain)
{
deduplicated.push(source);
}
}
deduplicated
}
fn normalized_domain(url: &str) -> String {
let without_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
without_scheme
.split('/')
.next()
.unwrap_or_default()
.trim_start_matches("www.")
.to_ascii_lowercase()
}
fn attribute(input: &str, name: &str) -> Option<String> {
let marker = format!("{name}=\"");
let start = input.find(&marker)? + marker.len();
let end = input[start..].find('"')? + start;
Some(input[start..end].replace("&", "&"))
}
fn default_discovery_backend() -> String {
"unknown".into()
}
fn classify(query: &str) -> &'static str {
if query.contains("package") {
"package_registry"
} else if query.contains("forge") || query.contains("Git") || query.contains("GitLab") {
"code_forge"
} else if query.contains("preprint") {
"preprint_server"
} else if query.contains("metadata") {
"aggregator_api"
} else {
"unclear"
}
}
fn strip_html(input: &str) -> String {
let mut out = String::new();
let mut in_tag = false;
for ch in input.chars() {
match ch {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => out.push(ch),
_ => {}
}
}
out.replace("&", "&")
.replace(""", "\"")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}