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