Skip to main content

paper_gap/
report.rs

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