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