paper-gap 0.3.1

Local CLI for finding research papers with missing, weak, or stale public code
Documentation
use paper_gap::{analyse, arxiv::Paper, provider, report, repos::Repository};

fn result(title: &str, score: u32) -> report::PaperResult {
    report::PaperResult {
        paper: Paper {
            id: title.into(),
            title: title.into(),
            abstract_text: "abstract".into(),
            authors: vec!["Ada".into()],
            published_date: Some("2026-01-01".into()),
            updated_date: None,
            doi: None,
            arxiv_id: Some("2601.00001".into()),
            url: "https://arxiv.org/abs/2601.00001".into(),
            categories: vec!["cs.PL".into()],
            extracted_methods: Vec::new(),
            source_provider: "arxiv".into(),
        },
        code_links: Vec::new(),
        repositories: Vec::new(),
        repository_matches: Vec::new(),
        repository_analyses: Vec::new(),
        pwc_outcome: provider::ProviderOutcome::success("papers-with-code"),
        repository_search_outcome: provider::ProviderOutcome::success("repository-search"),
        repository_inspection_outcomes: Vec::new(),
        gap_assessment: analyse::GapAssessment {
            implementation_gap_score: score,
            components: analyse::ScoreComponents {
                no_code_score: score,
                ..Default::default()
            },
            gaps: vec![analyse::Gap {
                gap_type: analyse::GapType::NoPublicImplementationFound,
                points: score,
                explanation: "because".into(),
            }],
        },
    }
}

#[test]
fn markdown_ranks_by_gap_score_and_explains_components() {
    let scan = report::ScanReport {
        schema_version: 1,
        provenance: Default::default(),
        paper_provider_outcomes: Vec::new(),
        paper_results: vec![result("low", 1), result("high", 50)],
    };
    let markdown = report::render(&scan, report::OutputFormat::Markdown).unwrap();
    assert!(markdown.find("## 1. high").unwrap() < markdown.find("## 2. low").unwrap());
    assert!(markdown.contains("Implementation gap score: 50"));
    assert!(markdown.contains("no_code_score: 50"));
    assert!(markdown.contains("NoPublicImplementationFound: +50 — because"));
}

#[test]
fn json_round_trips_scan_report() {
    let scan = report::ScanReport {
        schema_version: 1,
        provenance: report::ReportProvenance {
            generated_at: Some("2026-06-29T12:00:00Z".parse().unwrap()),
            operation: Some(report::OperationKind::Scan),
            selectors: report::ReportSelectors {
                query: Some("symbolic computation".into()),
                ..Default::default()
            },
            limit: Some(25),
            paper_source: Some("all".into()),
            repo_source: Some("github".into()),
        },
        paper_provider_outcomes: Vec::new(),
        paper_results: vec![result("paper", 50)],
    };
    let json = report::render(&scan, report::OutputFormat::Json).unwrap();
    let parsed: report::ScanReport = serde_json::from_str(&json).unwrap();
    assert_eq!(parsed.schema_version, 1);
    assert_eq!(
        parsed.provenance.operation,
        Some(report::OperationKind::Scan)
    );
    assert_eq!(
        parsed.provenance.selectors.query.as_deref(),
        Some("symbolic computation")
    );
    assert_eq!(parsed.provenance.repo_source.as_deref(), Some("github"));
    assert_eq!(parsed.paper_results.len(), 1);
    assert_eq!(
        parsed.paper_results[0]
            .gap_assessment
            .implementation_gap_score,
        50
    );
}

#[test]
fn markdown_shows_repository_quality_signals() {
    let mut result = result("paper", 10);
    result.repositories.push(Repository {
        forge: "github".into(),
        url: "https://github.com/a/b".into(),
        owner: "a".into(),
        name: "b".into(),
        description: None,
        primary_language: Some("Rust".into()),
        stars: None,
        forks: None,
        license: Some("MIT".into()),
        archived: Some(false),
        default_branch: Some("main".into()),
        last_commit_date: None,
        source_provider: "github".into(),
    });
    result
        .repository_matches
        .push(paper_gap::repos::RepositoryMatch {
            repository: result.repositories[0].clone(),
            confidence_score: 60,
            evidence: vec![paper_gap::repos::MatchEvidence {
                kind: "exact_title".into(),
                value: "paper".into(),
                points: 60,
            }],
        });
    result
        .repository_analyses
        .push(analyse::RepositoryAnalysis {
            inspection_succeeded: true,
            has_readme: true,
            has_license: true,
            has_tests: true,
            has_ci: true,
            has_docs: true,
            has_examples: true,
            has_package_manifest: true,
            has_reproducibility_scripts: true,
            has_benchmarks: true,
            is_notebook_only: false,
            is_stale: false,
            is_archived: false,
            primary_language: Some("Rust".into()),
        });

    let markdown = report::render(
        &report::ScanReport {
            schema_version: 1,
            provenance: Default::default(),
            paper_provider_outcomes: Vec::new(),
            paper_results: vec![result],
        },
        report::OutputFormat::Markdown,
    )
    .unwrap();
    assert!(markdown.contains("https://github.com/a/b (github)"));
    assert!(markdown.contains("match 60 [exact_title=paper (+60)]"));
    assert!(
        markdown.contains("README, license, tests, CI, docs, examples, package, repro, benchmarks")
    );
}

#[test]
fn old_json_defaults_provider_outcomes_to_success() {
    let mut value = serde_json::to_value(report::ScanReport {
        schema_version: 1,
        provenance: Default::default(),
        paper_provider_outcomes: Vec::new(),
        paper_results: vec![result("paper", 1)],
    })
    .unwrap();
    let item = value["paper_results"][0].as_object_mut().unwrap();
    item.remove("pwc_outcome");
    item.remove("repository_search_outcome");
    item.remove("repository_inspection_outcomes");
    item.remove("repository_matches");
    let parsed: report::ScanReport = serde_json::from_value(value).unwrap();
    assert!(parsed.paper_results[0].pwc_outcome.succeeded());
    assert!(parsed.paper_results[0].repository_matches.is_empty());
    assert!(
        parsed.paper_results[0]
            .repository_search_outcome
            .succeeded()
    );
}

#[test]
fn markdown_shows_provider_failure() {
    let mut result = result("paper", 0);
    result.pwc_outcome = provider::ProviderOutcome {
        provider: "papers-with-code".into(),
        status: provider::ProviderStatus::RateLimited,
        message: Some("HTTP 429".into()),
    };
    let markdown = report::render(
        &report::ScanReport {
            schema_version: 1,
            provenance: Default::default(),
            paper_provider_outcomes: Vec::new(),
            paper_results: vec![result],
        },
        report::OutputFormat::Markdown,
    )
    .unwrap();
    assert!(markdown.contains("Provider warning: papers-with-code rate_limited — HTTP 429"));
}

#[test]
fn old_json_defaults_missing_paper_provider_outcomes() {
    let mut value = serde_json::to_value(report::ScanReport {
        schema_version: 1,
        provenance: Default::default(),
        paper_provider_outcomes: Vec::new(),
        paper_results: vec![result("paper", 1)],
    })
    .unwrap();
    value
        .as_object_mut()
        .unwrap()
        .remove("paper_provider_outcomes");
    let parsed: report::ScanReport = serde_json::from_value(value).unwrap();
    assert!(parsed.paper_provider_outcomes.is_empty());
}

#[test]
fn markdown_shows_paper_provider_failure() {
    let scan = report::ScanReport {
        schema_version: 1,
        provenance: Default::default(),
        paper_provider_outcomes: vec![provider::ProviderOutcome {
            provider: "openalex".into(),
            status: provider::ProviderStatus::Unavailable,
            message: Some("timeout".into()),
        }],
        paper_results: Vec::new(),
    };
    let markdown = report::render(&scan, report::OutputFormat::Markdown).unwrap();
    assert!(markdown.contains("Paper provider warning: openalex unavailable — timeout"));
}

#[test]
fn old_json_defaults_schema_and_provenance() {
    let parsed: report::ScanReport = serde_json::from_str(r#"{"paper_results":[]}"#).unwrap();
    assert_eq!(parsed.schema_version, 0);
    assert!(parsed.provenance.operation.is_none());
    let markdown = report::render(&parsed, report::OutputFormat::Markdown).unwrap();
    assert!(!markdown.contains("Schema version:"));
    assert!(!markdown.contains("Operation:"));
}

#[test]
fn markdown_shows_report_provenance() {
    let scan = report::ScanReport {
        schema_version: 1,
        provenance: report::ReportProvenance {
            generated_at: Some("2026-06-29T12:00:00Z".parse().unwrap()),
            operation: Some(report::OperationKind::Inspect),
            selectors: report::ReportSelectors {
                arxiv: Some("2603.21852".into()),
                ..Default::default()
            },
            limit: Some(1),
            paper_source: Some("arxiv".into()),
            repo_source: Some("none".into()),
        },
        paper_provider_outcomes: Vec::new(),
        paper_results: Vec::new(),
    };
    let markdown = report::render(&scan, report::OutputFormat::Markdown).unwrap();
    assert!(markdown.contains("Schema version: 1"));
    assert!(markdown.contains("Operation: inspect"));
    assert!(markdown.contains("Selectors: arxiv=2603.21852"));
    assert!(markdown.contains("Repository source: none"));
}