paper-gap 0.1.5

Local CLI for finding research papers with missing, weak, or stale public code
Documentation
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>> {
    // ponytail: DuckDuckGo HTML is brittle; replace with a real search API if discovery becomes product-critical.
    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("&amp;", "&")
        .replace("&quot;", "\"")
        .split_whitespace()
        .collect::<Vec<_>>()
        .join(" ")
}