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}