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 {
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: "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 mut sources = Vec::new();
for query in discovery_queries(kind) {
let url = format!(
"https://duckduckgo.com/html/?q={}",
urlencoding::encode(query)
);
let body = client.get(&url).send().await?.text().await?;
sources.extend(parse_duckduckgo_html(kind, query, &url, &body));
}
Ok(sources)
}
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();
DiscoveredSource {
name: if name.is_empty() { query.into() } else { name },
url: search_url.into(),
kind: match kind {
DiscoverKind::Papers => "papers",
DiscoverKind::Repos => "repos",
DiscoverKind::All => "both",
}
.into(),
source_type: classify(query).into(),
auth: "unknown".into(),
api_available: "unknown".into(),
docs_url: None,
coverage_notes: format!("Discovered from DuckDuckGo query: {query}"),
integration_difficulty: "unknown".into(),
confidence: "low".into(),
evidence_links: vec![search_url.into()],
evidence_snippets: vec![strip_html(line)],
}
})
.collect()
}
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(" ")
}