use crate::{Result, analyse, arxiv, provider, pwc, repos};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct ScanReport {
#[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 = format!(
"# paper-gap report\n\n{} 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_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
));
}
}