paper-gap 0.1.7

Local CLI for finding research papers with missing, weak, or stale public code
Documentation
use chrono::NaiveDate;
use clap::{Args, Parser, Subcommand, ValueEnum};
use paper_gap::{Result, analyse, arxiv, pwc, report, repos, sources};
use std::fs;
use std::io::{self, Read};
use std::path::PathBuf;

#[derive(Parser)]
#[command(
    name = "paper-gap",
    about = "Find research papers with weak or missing public code"
)]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    Scan(ScanArgs),
    Inspect(InspectArgs),
    Report(ReportArgs),
    Sources(SourcesArgs),
}

#[derive(Args)]
struct ScanArgs {
    #[arg(long)]
    query: Option<String>,
    #[arg(long)]
    category: Option<String>,
    #[arg(long)]
    since: Option<String>,
    #[arg(long)]
    from: Option<NaiveDate>,
    #[arg(long)]
    to: Option<NaiveDate>,
    #[arg(long, default_value_t = 25)]
    limit: usize,
    #[arg(long, value_enum, default_value_t = OutputFormat::Markdown)]
    format: OutputFormat,
    #[arg(long)]
    output: Option<PathBuf>,
}

#[derive(Args)]
struct InspectArgs {
    #[arg(long)]
    arxiv: String,
    #[arg(long, value_enum, default_value_t = OutputFormat::Markdown)]
    format: OutputFormat,
    #[arg(long)]
    output: Option<PathBuf>,
}

#[derive(Args)]
struct ReportArgs {
    #[arg(long, default_value = "-")]
    input: String,
    #[arg(long, value_enum, default_value_t = OutputFormat::Markdown)]
    format: OutputFormat,
    #[arg(long)]
    output: Option<PathBuf>,
}

#[derive(Args)]
struct SourcesArgs {
    #[command(subcommand)]
    command: Option<SourcesCommand>,
    #[arg(long)]
    papers: bool,
    #[arg(long)]
    repos: bool,
    #[arg(long)]
    check: bool,
}

#[derive(Subcommand)]
enum SourcesCommand {
    Discover(DiscoverArgs),
}

#[derive(Args)]
struct DiscoverArgs {
    #[arg(long, value_enum, default_value_t = DiscoverKind::All)]
    kind: DiscoverKind,
}

#[derive(Clone, ValueEnum)]
enum DiscoverKind {
    Papers,
    Repos,
    All,
}
#[derive(Clone, Copy, ValueEnum)]
enum OutputFormat {
    Markdown,
    Json,
}

impl From<OutputFormat> for report::OutputFormat {
    fn from(value: OutputFormat) -> Self {
        match value {
            OutputFormat::Markdown => Self::Markdown,
            OutputFormat::Json => Self::Json,
        }
    }
}

#[tokio::main]
async fn main() -> Result<()> {
    let cli = Cli::parse();
    match cli.command {
        Command::Scan(args) => scan(args).await,
        Command::Inspect(args) => inspect(args).await,
        Command::Report(args) => report(args),
        Command::Sources(args) => sources(args).await,
    }
}

async fn scan(args: ScanArgs) -> Result<()> {
    let query = arxiv::query_string(&arxiv::PaperQuery {
        query: args.query,
        category: args.category,
        since: args.since,
        from: args.from,
        to: args.to,
    })?;
    eprintln!("paper-gap: fetching arXiv papers...");
    let papers = arxiv::search(&query, args.limit).await?;
    eprintln!(
        "paper-gap: found {} paper(s); checking code links and repositories...",
        papers.len()
    );
    let report = build_report(papers).await?;
    write_output(&render_report(&report, args.format)?, args.output)
}

async fn inspect(args: InspectArgs) -> Result<()> {
    eprintln!("paper-gap: fetching arXiv paper {}...", args.arxiv);
    let papers = arxiv::search(&format!("id:{}", args.arxiv), 1).await?;
    eprintln!(
        "paper-gap: found {} paper(s); checking code links and repositories...",
        papers.len()
    );
    let report = build_report(papers).await?;
    write_output(&render_report(&report, args.format)?, args.output)
}

fn report(args: ReportArgs) -> Result<()> {
    let input = if args.input == "-" {
        let mut s = String::new();
        io::stdin().read_to_string(&mut s)?;
        s
    } else {
        fs::read_to_string(&args.input)?
    };
    let report: report::ScanReport = serde_json::from_str(&input)?;
    write_output(&render_report(&report, args.format)?, args.output)
}

async fn sources(args: SourcesArgs) -> Result<()> {
    if let Some(SourcesCommand::Discover(args)) = args.command {
        let kind = match args.kind {
            DiscoverKind::Papers => sources::DiscoverKind::Papers,
            DiscoverKind::Repos => sources::DiscoverKind::Repos,
            DiscoverKind::All => sources::DiscoverKind::All,
        };
        println!(
            "{}",
            serde_json::to_string_pretty(&sources::discover(kind).await?)?
        );
        return Ok(());
    }

    let filter = match (args.papers, args.repos) {
        (true, false) => sources::SourceFilter::Papers,
        (false, true) => sources::SourceFilter::Repos,
        _ => sources::SourceFilter::All,
    };
    let providers = sources::filter(sources::builtin(), filter);
    if args.check {
        for provider in &providers {
            println!(
                "{}	{}	{}",
                provider.id,
                provider.display_name,
                sources::health_check(provider.base_url).await
            );
        }
    } else {
        println!("{}", serde_json::to_string_pretty(&providers)?);
    }
    Ok(())
}

async fn build_report(papers: Vec<arxiv::Paper>) -> Result<report::ScanReport> {
    let mut paper_results = Vec::new();
    for (index, paper) in papers.into_iter().enumerate() {
        eprintln!(
            "paper-gap: [{}] checking Papers with Code: {}",
            index + 1,
            paper.title
        );
        let code_links = pwc::code_links(&paper).await.unwrap_or_default();
        eprintln!("paper-gap: [{}] searching repositories...", index + 1);
        let repositories = repos::search_for_paper(&paper, 5).await.unwrap_or_default();
        let repositories = analyse::dedup_repositories(repositories);
        eprintln!(
            "paper-gap: [{}] inspecting repository quality...",
            index + 1
        );
        let mut repository_analyses = Vec::new();
        for repository in &repositories {
            let paths = repos::file_paths(repository).await.unwrap_or_default();
            repository_analyses.push(analyse::analyze_repository_with_paths(repository, &paths));
        }
        let gap_assessment =
            analyse::assess_with_analyses(&code_links, &repositories, &repository_analyses);
        paper_results.push(report::PaperResult {
            paper,
            code_links,
            repositories,
            repository_analyses,
            gap_assessment,
        });
    }
    Ok(report::ScanReport { paper_results })
}

fn render_report(report: &report::ScanReport, format: OutputFormat) -> Result<String> {
    report::render(report, format.into())
}

fn write_output(text: &str, output: Option<PathBuf>) -> Result<()> {
    if let Some(path) = output {
        fs::write(path, text)?;
    } else {
        println!("{}", text);
    }
    Ok(())
}