paper-gap 0.2.2

Local CLI for finding research papers with missing, weak, or stale public code
Documentation
use crate::{Result, arxiv, paper::Paper};
use chrono::NaiveDate;
use serde::Deserialize;
use std::{collections::HashMap, time::Duration};

const BASE_URL: &str = "https://api.openalex.org/works";

#[derive(Debug, Clone)]
pub struct SearchQuery {
    pub query: Option<String>,
    pub from: Option<NaiveDate>,
    pub to: Option<NaiveDate>,
    pub limit: usize,
}

pub fn search_url(query: &SearchQuery, email: Option<&str>) -> String {
    let mut url = format!(
        "{BASE_URL}?per-page={}&sort=publication_date:desc",
        query.limit
    );
    if let Some(search) = query
        .query
        .as_deref()
        .filter(|query| !query.trim().is_empty())
    {
        url.push_str("&search=");
        url.push_str(&urlencoding::encode(search));
    }
    let mut filters = Vec::new();
    if let Some(from) = query.from {
        filters.push(format!("from_publication_date:{from}"));
    }
    if let Some(to) = query.to {
        filters.push(format!("to_publication_date:{to}"));
    }
    if !filters.is_empty() {
        url.push_str("&filter=");
        url.push_str(&urlencoding::encode(&filters.join(",")));
    }
    if let Some(email) = email.filter(|email| !email.trim().is_empty()) {
        url.push_str("&mailto=");
        url.push_str(&urlencoding::encode(email));
    }
    url
}

pub async fn search(query: &SearchQuery) -> Result<Vec<Paper>> {
    let client = reqwest::Client::builder()
        .user_agent("paper-gap/0.2")
        .timeout(Duration::from_secs(20))
        .build()?;
    let email = std::env::var("OPENALEX_EMAIL").ok();
    let body = client
        .get(search_url(query, email.as_deref()))
        .send()
        .await?
        .error_for_status()?
        .text()
        .await?;
    parse(&body)
}

pub fn parse(json: &str) -> Result<Vec<Paper>> {
    let response: Response = serde_json::from_str(json)?;
    Ok(response.results.into_iter().map(normalize).collect())
}

#[derive(Deserialize)]
struct Response {
    results: Vec<Work>,
}

#[derive(Deserialize)]
struct Work {
    id: String,
    title: String,
    doi: Option<String>,
    publication_date: Option<String>,
    updated_date: Option<String>,
    abstract_inverted_index: Option<HashMap<String, Vec<usize>>>,
    authorships: Vec<Authorship>,
    ids: HashMap<String, String>,
    primary_location: Option<Location>,
    #[serde(default)]
    topics: Vec<Topic>,
}

#[derive(Deserialize)]
struct Authorship {
    author: Author,
}

#[derive(Deserialize)]
struct Author {
    display_name: String,
}

#[derive(Deserialize)]
struct Location {
    landing_page_url: Option<String>,
}

#[derive(Deserialize)]
struct Topic {
    display_name: String,
}

fn normalize(work: Work) -> Paper {
    let abstract_text = reconstruct_abstract(work.abstract_inverted_index.as_ref());
    let arxiv_id = work
        .ids
        .get("arxiv")
        .and_then(|url| url.rsplit('/').next())
        .map(str::to_string);
    let doi = work
        .doi
        .as_deref()
        .map(|doi| doi.trim_start_matches("https://doi.org/").to_string());
    let url = work
        .primary_location
        .and_then(|location| location.landing_page_url)
        .unwrap_or_else(|| work.id.clone());
    let extracted_methods = arxiv::extract_acronyms(&format!("{} {}", work.title, abstract_text));

    Paper {
        id: work.id,
        title: work.title,
        abstract_text,
        authors: work
            .authorships
            .into_iter()
            .map(|entry| entry.author.display_name)
            .collect(),
        published_date: work.publication_date,
        updated_date: work.updated_date,
        doi,
        arxiv_id,
        url,
        categories: work
            .topics
            .into_iter()
            .map(|topic| topic.display_name)
            .collect(),
        extracted_methods,
        source_provider: "openalex".into(),
    }
}

fn reconstruct_abstract(index: Option<&HashMap<String, Vec<usize>>>) -> String {
    let Some(index) = index else {
        return String::new();
    };
    let Some(max) = index.values().flatten().max().copied() else {
        return String::new();
    };
    let mut words = vec![String::new(); max + 1];
    for (word, positions) in index {
        for &position in positions {
            words[position] = word.clone();
        }
    }
    words
        .into_iter()
        .filter(|word| !word.is_empty())
        .collect::<Vec<_>>()
        .join(" ")
}