paper-gap 0.3.1

Local CLI for finding research papers with missing, weak, or stale public code
Documentation
use crate::{Result, err, provider};
use chrono::{Duration, NaiveDate, Utc};
use quick_xml::Reader;
use quick_xml::events::Event;

pub use crate::paper::Paper;

#[derive(Debug, Clone, Default)]
pub struct PaperQuery {
    pub query: Option<String>,
    pub category: Option<String>,
    pub since: Option<String>,
    pub from: Option<NaiveDate>,
    pub to: Option<NaiveDate>,
}

pub fn query_string(args: &PaperQuery) -> Result<String> {
    let mut parts = Vec::new();
    if let Some(query) = &args.query {
        parts.push(format!("all:{}", query));
    }
    if let Some(category) = &args.category {
        parts.push(format!("cat:{}", category));
    }
    if parts.is_empty() {
        return Err(err("scan needs --query, --category, or both"));
    }
    let (from, to) = date_range(args)?;
    if from.is_some() || to.is_some() {
        let from = from
            .map(|date| format!("{}0000", date.format("%Y%m%d")))
            .unwrap_or_else(|| "*".into());
        let to = to
            .map(|date| format!("{}2359", date.format("%Y%m%d")))
            .unwrap_or_else(|| "*".into());
        parts.push(format!("submittedDate:[{from} TO {to}]"));
    }
    Ok(parts.join(" AND "))
}

pub fn date_range(args: &PaperQuery) -> Result<(Option<NaiveDate>, Option<NaiveDate>)> {
    if args.since.is_some() && (args.from.is_some() || args.to.is_some()) {
        return Err(err("--since cannot be combined with --from or --to"));
    }
    if let Some(since) = &args.since {
        let days = since
            .strip_suffix('d')
            .ok_or_else(|| err("--since only supports Nd, e.g. 30d"))?
            .parse::<u64>()?;
        let days = i64::try_from(days).map_err(|_| err("--since is too large"))?;
        let duration = Duration::try_days(days).ok_or_else(|| err("--since is too large"))?;
        let today = Utc::now().date_naive();
        let from = today
            .checked_sub_signed(duration)
            .ok_or_else(|| err("--since is too large"))?;
        return Ok((Some(from), Some(today)));
    }
    if args.from.zip(args.to).is_some_and(|(from, to)| from > to) {
        return Err(err("--from must not be after --to"));
    }
    Ok((args.from, args.to))
}

pub fn search_url(query: &str, limit: usize) -> String {
    format!(
        "https://export.arxiv.org/api/query?search_query={}&start=0&max_results={}&sortBy=submittedDate&sortOrder=descending",
        urlencoding::encode(query),
        limit
    )
}

pub async fn search(query: &str, limit: usize) -> Result<Vec<Paper>> {
    let client = reqwest::Client::builder()
        .user_agent("paper-gap/0.1 (local on-demand CLI; contact: unknown)")
        .timeout(std::time::Duration::from_secs(20))
        .build()?;
    let response = provider::send_with_retry(client.get(search_url(query, limit))).await?;
    let body = response.error_for_status()?.text().await?;
    parse(&body)
}

pub fn parse(xml: &str) -> Result<Vec<Paper>> {
    let mut reader = Reader::from_str(xml);
    reader.config_mut().trim_text(true);
    let mut papers = Vec::new();
    let mut current: Option<Paper> = None;
    let mut field = String::new();
    let mut in_entry = false;
    let mut author_name = false;
    loop {
        match reader.read_event()? {
            Event::Start(e) => {
                let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
                if name == "entry" {
                    in_entry = true;
                    current = Some(Paper {
                        id: String::new(),
                        title: String::new(),
                        abstract_text: String::new(),
                        authors: Vec::new(),
                        published_date: None,
                        updated_date: None,
                        doi: None,
                        arxiv_id: None,
                        url: String::new(),
                        categories: Vec::new(),
                        extracted_methods: Vec::new(),
                        source_provider: "arxiv".into(),
                    });
                } else if in_entry && name == "category" {
                    if let Some(term) = e.attributes().flatten().find(|a| a.key.as_ref() == b"term")
                    {
                        if let Some(p) = &mut current {
                            p.categories
                                .push(String::from_utf8_lossy(&term.value).to_string());
                        }
                    }
                } else {
                    author_name = in_entry && name == "name";
                    field = name;
                }
            }
            Event::Empty(e) => {
                let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
                if in_entry && name == "category" {
                    if let Some(term) = e.attributes().flatten().find(|a| a.key.as_ref() == b"term")
                    {
                        if let Some(p) = &mut current {
                            p.categories
                                .push(String::from_utf8_lossy(&term.value).to_string());
                        }
                    }
                }
            }
            Event::Text(e) => {
                if let Some(p) = &mut current {
                    let text = e.unescape()?.to_string();
                    match field.as_str() {
                        "id" => {
                            p.id = text.clone();
                            p.url = text.clone();
                            p.arxiv_id = text.rsplit('/').next().map(|s| s.to_string());
                        }
                        "title" => p.title.push_str(clean(&text).as_str()),
                        "summary" => p.abstract_text.push_str(clean(&text).as_str()),
                        "published" => p.published_date = Some(text),
                        "updated" => p.updated_date = Some(text),
                        "doi" | "arxiv:doi" => p.doi = Some(text),
                        "name" if author_name => p.authors.push(text),
                        _ => {}
                    }
                }
            }
            Event::End(e) => {
                let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
                if name == "entry" {
                    if let Some(mut p) = current.take() {
                        p.extracted_methods =
                            extract_acronyms(&format!("{} {}", p.title, p.abstract_text));
                        papers.push(p);
                    }
                    in_entry = false;
                }
                field.clear();
                author_name = false;
            }
            Event::Eof => break,
            _ => {}
        }
    }
    Ok(papers)
}

pub fn clean(s: &str) -> String {
    s.split_whitespace().collect::<Vec<_>>().join(" ")
}

pub fn extract_acronyms(text: &str) -> Vec<String> {
    let mut out = Vec::new();
    for word in text.split(|c: char| !c.is_alphanumeric() && c != '-') {
        if word.len() > 1
            && word.len() <= 12
            && word.chars().any(|c| c.is_ascii_uppercase())
            && word
                .chars()
                .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '-')
            && !out.iter().any(|w| w == word)
        {
            out.push(word.to_string());
        }
    }
    out
}