Skip to main content

paper_gap/
report.rs

1use crate::{Result, analyse, arxiv, provider, pwc, repos};
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Serialize, Deserialize)]
5pub struct ScanReport {
6    pub paper_results: Vec<PaperResult>,
7}
8
9#[derive(Debug, Serialize, Deserialize)]
10pub struct PaperResult {
11    pub paper: arxiv::Paper,
12    pub code_links: Vec<pwc::CodeLink>,
13    pub repositories: Vec<repos::Repository>,
14    pub repository_analyses: Vec<analyse::RepositoryAnalysis>,
15    #[serde(default = "provider::default_success")]
16    pub pwc_outcome: provider::ProviderOutcome,
17    #[serde(default = "provider::default_success")]
18    pub repository_search_outcome: provider::ProviderOutcome,
19    #[serde(default)]
20    pub repository_inspection_outcomes: Vec<provider::ProviderOutcome>,
21    pub gap_assessment: analyse::GapAssessment,
22}
23
24#[derive(Clone, Copy)]
25pub enum OutputFormat {
26    Markdown,
27    Json,
28}
29
30pub fn render(report: &ScanReport, format: OutputFormat) -> Result<String> {
31    Ok(match format {
32        OutputFormat::Json => serde_json::to_string_pretty(report)?,
33        OutputFormat::Markdown => markdown(report),
34    })
35}
36
37fn markdown(report: &ScanReport) -> String {
38    let mut results: Vec<_> = report.paper_results.iter().collect();
39    results.sort_by_key(|result| std::cmp::Reverse(result.gap_assessment.implementation_gap_score));
40
41    let mut out = format!(
42        "# paper-gap report\n\n{} papers analysed. Ranked by implementation gap score.\n\n",
43        results.len()
44    );
45
46    for (index, result) in results.iter().enumerate() {
47        let p = &result.paper;
48        out.push_str(&format!(
49            "## {}. {}\n\n- Implementation gap score: {}\n- arXiv: {}\n- Published: {}\n- Authors: {}\n- Categories: {}\n",
50            index + 1,
51            p.title,
52            result.gap_assessment.implementation_gap_score,
53            p.arxiv_id.as_deref().unwrap_or(""),
54            p.published_date.as_deref().unwrap_or("unknown"),
55            p.authors.join(", "),
56            p.categories.join(", ")
57        ));
58
59        push_code_links(&mut out, &result.code_links);
60        push_repositories(&mut out, &result.repositories, &result.repository_analyses);
61        push_provider_warnings(&mut out, result);
62        push_score(&mut out, &result.gap_assessment);
63
64        out.push_str(&format!("\n{}\n\n", p.abstract_text));
65    }
66
67    out
68}
69
70fn push_code_links(out: &mut String, links: &[pwc::CodeLink]) {
71    if links.is_empty() {
72        out.push_str("- Papers with Code links: none known\n");
73        return;
74    }
75    out.push_str("- Papers with Code links:\n");
76    for link in links {
77        out.push_str(&format!("  - {}\n", link.repository_url));
78    }
79}
80
81fn push_repositories(
82    out: &mut String,
83    repositories: &[repos::Repository],
84    analyses: &[analyse::RepositoryAnalysis],
85) {
86    if repositories.is_empty() {
87        out.push_str(
88            "- Repository search results: none found
89",
90        );
91        return;
92    }
93    out.push_str(
94        "- Repository search results:
95",
96    );
97    for (index, repo) in repositories.iter().enumerate() {
98        let signals = analyses
99            .get(index)
100            .map(quality_signals)
101            .unwrap_or_else(|| vec!["signals: none".to_string()]);
102        out.push_str(&format!(
103            "  - {} ({}) — {}
104",
105            repo.url,
106            repo.forge,
107            signals.join(", ")
108        ));
109    }
110}
111
112fn quality_signals(analysis: &analyse::RepositoryAnalysis) -> Vec<String> {
113    let mut signals = Vec::new();
114    if analysis.has_readme {
115        signals.push("README");
116    }
117    if analysis.has_license {
118        signals.push("license");
119    }
120    if analysis.has_tests {
121        signals.push("tests");
122    }
123    if analysis.has_ci {
124        signals.push("CI");
125    }
126    if analysis.has_docs {
127        signals.push("docs");
128    }
129    if analysis.has_examples {
130        signals.push("examples");
131    }
132    if analysis.has_package_manifest {
133        signals.push("package");
134    }
135    if analysis.has_reproducibility_scripts {
136        signals.push("repro");
137    }
138    if analysis.has_benchmarks {
139        signals.push("benchmarks");
140    }
141    if analysis.is_notebook_only {
142        signals.push("notebook-only");
143    }
144    if analysis.is_stale {
145        signals.push("stale");
146    }
147    if analysis.is_archived {
148        signals.push("archived");
149    }
150    if signals.is_empty() {
151        vec!["signals: none".to_string()]
152    } else {
153        signals.into_iter().map(str::to_string).collect()
154    }
155}
156
157fn push_provider_warnings(out: &mut String, result: &PaperResult) {
158    let outcomes = std::iter::once(&result.pwc_outcome)
159        .chain(std::iter::once(&result.repository_search_outcome))
160        .chain(result.repository_inspection_outcomes.iter());
161    for outcome in outcomes.filter(|outcome| !outcome.succeeded()) {
162        out.push_str(&format!(
163            "- Provider warning: {} {:?}{}\n",
164            outcome.provider,
165            outcome.status,
166            outcome
167                .message
168                .as_deref()
169                .map(|message| format!(" — {message}"))
170                .unwrap_or_default()
171        ));
172    }
173}
174
175fn push_score(out: &mut String, assessment: &analyse::GapAssessment) {
176    let c = &assessment.components;
177    out.push_str("- Score components:\n");
178    out.push_str(&format!("  - no_code_score: {}\n", c.no_code_score));
179    out.push_str(&format!(
180        "  - prototype_only_score: {}\n",
181        c.prototype_only_score
182    ));
183    out.push_str(&format!("  - stale_repo_score: {}\n", c.stale_repo_score));
184    out.push_str(&format!(
185        "  - packaging_gap_score: {}\n",
186        c.packaging_gap_score
187    ));
188    out.push_str(&format!("  - docs_gap_score: {}\n", c.docs_gap_score));
189    out.push_str(&format!("  - tests_gap_score: {}\n", c.tests_gap_score));
190    out.push_str(&format!("  - license_gap_score: {}\n", c.license_gap_score));
191    out.push_str(&format!(
192        "  - reproducibility_gap_score: {}\n",
193        c.reproducibility_gap_score
194    ));
195    out.push_str(&format!(
196        "  - ecosystem_gap_score: {}\n",
197        c.ecosystem_gap_score
198    ));
199
200    if assessment.gaps.is_empty() {
201        out.push_str("- Gap reasons: none\n");
202        return;
203    }
204    out.push_str("- Gap reasons:\n");
205    for gap in &assessment.gaps {
206        out.push_str(&format!(
207            "  - {:?}: +{} — {}\n",
208            gap.gap_type, gap.points, gap.explanation
209        ));
210    }
211}