paper-gap 0.3.2

Local CLI for finding research papers with missing, weak, or stale public code
Documentation
use crate::{Result, analyse, arxiv, provider, pwc, repos};
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct ReportProvenance {
    pub generated_at: Option<DateTime<Utc>>,
    pub operation: Option<OperationKind>,
    #[serde(default)]
    pub selectors: ReportSelectors,
    pub limit: Option<usize>,
    pub paper_source: Option<String>,
    pub repo_source: Option<String>,
}

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

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum OperationKind {
    Scan,
    Inspect,
}

impl OperationKind {
    fn as_str(self) -> &'static str {
        match self {
            Self::Scan => "scan",
            Self::Inspect => "inspect",
        }
    }
}

#[derive(Debug, Serialize, Deserialize)]
pub struct ScanReport {
    #[serde(default)]
    pub schema_version: u32,
    #[serde(default)]
    pub provenance: ReportProvenance,
    #[serde(default)]
    pub paper_provider_outcomes: Vec<provider::ProviderOutcome>,
    pub paper_results: Vec<PaperResult>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct PaperResult {
    pub paper: arxiv::Paper,
    pub code_links: Vec<pwc::CodeLink>,
    pub repositories: Vec<repos::Repository>,
    #[serde(default)]
    pub repository_matches: Vec<repos::RepositoryMatch>,
    pub repository_analyses: Vec<analyse::RepositoryAnalysis>,
    #[serde(default = "provider::default_success")]
    pub pwc_outcome: provider::ProviderOutcome,
    #[serde(default = "provider::default_success")]
    pub repository_search_outcome: provider::ProviderOutcome,
    #[serde(default)]
    pub repository_inspection_outcomes: Vec<provider::ProviderOutcome>,
    pub gap_assessment: analyse::GapAssessment,
}

#[derive(Clone, Copy)]
pub enum OutputFormat {
    Markdown,
    Json,
}

pub fn render(report: &ScanReport, format: OutputFormat) -> Result<String> {
    Ok(match format {
        OutputFormat::Json => serde_json::to_string_pretty(report)?,
        OutputFormat::Markdown => markdown(report),
    })
}

fn markdown(report: &ScanReport) -> String {
    let mut results: Vec<_> = report.paper_results.iter().collect();
    results.sort_by_key(|result| std::cmp::Reverse(result.gap_assessment.implementation_gap_score));

    let mut out = String::from("# paper-gap report\n\n");
    push_provenance(&mut out, report);
    out.push_str(&format!(
        "{} papers analysed. Ranked by implementation gap score.\n\n",
        results.len()
    ));
    for outcome in report
        .paper_provider_outcomes
        .iter()
        .filter(|outcome| !outcome.succeeded())
    {
        out.push_str(&format!(
            "- Paper provider warning: {} {}{}\n",
            outcome.provider,
            outcome.status,
            outcome
                .message
                .as_deref()
                .map(|message| format!("{message}"))
                .unwrap_or_default()
        ));
    }
    if report
        .paper_provider_outcomes
        .iter()
        .any(|outcome| !outcome.succeeded())
    {
        out.push('\n');
    }

    for (index, result) in results.iter().enumerate() {
        let p = &result.paper;
        out.push_str(&format!(
            "## {}. {}\n\n- Implementation gap score: {}\n- arXiv: {}\n- Published: {}\n- Authors: {}\n- Categories: {}\n",
            index + 1,
            p.title,
            result.gap_assessment.implementation_gap_score,
            p.arxiv_id.as_deref().unwrap_or(""),
            p.published_date.as_deref().unwrap_or("unknown"),
            p.authors.join(", "),
            p.categories.join(", ")
        ));

        push_code_links(&mut out, &result.code_links);
        push_repositories(
            &mut out,
            &result.repositories,
            &result.repository_matches,
            &result.repository_analyses,
        );
        push_provider_warnings(&mut out, result);
        push_score(&mut out, &result.gap_assessment);

        out.push_str(&format!("\n{}\n\n", p.abstract_text));
    }

    out
}

fn push_provenance(out: &mut String, report: &ScanReport) {
    if report.schema_version > 0 {
        out.push_str(&format!("- Schema version: {}\n", report.schema_version));
    }
    let provenance = &report.provenance;
    if let Some(generated_at) = provenance.generated_at {
        out.push_str(&format!("- Generated: {}\n", generated_at.to_rfc3339()));
    }
    if let Some(operation) = provenance.operation {
        out.push_str(&format!("- Operation: {}\n", operation.as_str()));
    }
    let selectors = &provenance.selectors;
    let values = [
        ("query", selectors.query.as_deref().map(str::to_string)),
        (
            "category",
            selectors.category.as_deref().map(str::to_string),
        ),
        ("since", selectors.since.as_deref().map(str::to_string)),
        ("from", selectors.from.map(|value| value.to_string())),
        ("to", selectors.to.map(|value| value.to_string())),
        ("arxiv", selectors.arxiv.as_deref().map(str::to_string)),
    ]
    .into_iter()
    .filter_map(|(name, value)| value.map(|value| format!("{name}={value}")))
    .collect::<Vec<_>>();
    if !values.is_empty() {
        out.push_str(&format!("- Selectors: {}\n", values.join(", ")));
    }
    if let Some(limit) = provenance.limit {
        out.push_str(&format!("- Limit: {limit}\n"));
    }
    if let Some(source) = &provenance.paper_source {
        out.push_str(&format!("- Paper source: {source}\n"));
    }
    if let Some(source) = &provenance.repo_source {
        out.push_str(&format!("- Repository source: {source}\n"));
    }
    if report.schema_version > 0 || provenance.operation.is_some() {
        out.push('\n');
    }
}

fn push_code_links(out: &mut String, links: &[pwc::CodeLink]) {
    if links.is_empty() {
        out.push_str("- Papers with Code links: none known\n");
        return;
    }
    out.push_str("- Papers with Code links:\n");
    for link in links {
        out.push_str(&format!("  - {}\n", link.repository_url));
    }
}

fn push_repositories(
    out: &mut String,
    repositories: &[repos::Repository],
    matches: &[repos::RepositoryMatch],
    analyses: &[analyse::RepositoryAnalysis],
) {
    if repositories.is_empty() {
        out.push_str(
            "- Repository search results: none found
",
        );
        return;
    }
    out.push_str(
        "- Repository search results:
",
    );
    for (index, repo) in repositories.iter().enumerate() {
        let signals = analyses
            .get(index)
            .map(quality_signals)
            .unwrap_or_else(|| vec!["signals: none".to_string()]);
        let match_summary = matches
            .iter()
            .find(|matched| matched.repository.url == repo.url)
            .map(|matched| {
                let evidence = matched
                    .evidence
                    .iter()
                    .map(|item| format!("{}={} (+{})", item.kind, item.value, item.points))
                    .collect::<Vec<_>>()
                    .join("; ");
                format!("match {} [{}]", matched.confidence_score, evidence)
            })
            .unwrap_or_else(|| "match unknown".into());
        out.push_str(&format!(
            "  - {} ({}) — {}{}
",
            repo.url,
            repo.forge,
            match_summary,
            signals.join(", ")
        ));
    }
}

fn quality_signals(analysis: &analyse::RepositoryAnalysis) -> Vec<String> {
    let mut signals = Vec::new();
    if analysis.has_readme {
        signals.push("README");
    }
    if analysis.has_license {
        signals.push("license");
    }
    if analysis.has_tests {
        signals.push("tests");
    }
    if analysis.has_ci {
        signals.push("CI");
    }
    if analysis.has_docs {
        signals.push("docs");
    }
    if analysis.has_examples {
        signals.push("examples");
    }
    if analysis.has_package_manifest {
        signals.push("package");
    }
    if analysis.has_reproducibility_scripts {
        signals.push("repro");
    }
    if analysis.has_benchmarks {
        signals.push("benchmarks");
    }
    if analysis.is_notebook_only {
        signals.push("notebook-only");
    }
    if analysis.is_stale {
        signals.push("stale");
    }
    if analysis.is_archived {
        signals.push("archived");
    }
    if signals.is_empty() {
        vec!["signals: none".to_string()]
    } else {
        signals.into_iter().map(str::to_string).collect()
    }
}

fn push_provider_warnings(out: &mut String, result: &PaperResult) {
    let outcomes = std::iter::once(&result.pwc_outcome)
        .chain(std::iter::once(&result.repository_search_outcome))
        .chain(result.repository_inspection_outcomes.iter());
    for outcome in outcomes.filter(|outcome| !outcome.succeeded()) {
        out.push_str(&format!(
            "- Provider warning: {} {}{}\n",
            outcome.provider,
            outcome.status,
            outcome
                .message
                .as_deref()
                .map(|message| format!("{message}"))
                .unwrap_or_default()
        ));
    }
}

fn push_score(out: &mut String, assessment: &analyse::GapAssessment) {
    let c = &assessment.components;
    out.push_str("- Score components:\n");
    out.push_str(&format!("  - no_code_score: {}\n", c.no_code_score));
    out.push_str(&format!(
        "  - prototype_only_score: {}\n",
        c.prototype_only_score
    ));
    out.push_str(&format!("  - stale_repo_score: {}\n", c.stale_repo_score));
    out.push_str(&format!(
        "  - packaging_gap_score: {}\n",
        c.packaging_gap_score
    ));
    out.push_str(&format!("  - docs_gap_score: {}\n", c.docs_gap_score));
    out.push_str(&format!("  - tests_gap_score: {}\n", c.tests_gap_score));
    out.push_str(&format!("  - license_gap_score: {}\n", c.license_gap_score));
    out.push_str(&format!(
        "  - reproducibility_gap_score: {}\n",
        c.reproducibility_gap_score
    ));
    out.push_str(&format!(
        "  - ecosystem_gap_score: {}\n",
        c.ecosystem_gap_score
    ));

    if assessment.gaps.is_empty() {
        out.push_str("- Gap reasons: none\n");
        return;
    }
    out.push_str("- Gap reasons:\n");
    for gap in &assessment.gaps {
        out.push_str(&format!(
            "  - {:?}: +{}{}\n",
            gap.gap_type, gap.points, gap.explanation
        ));
    }
}