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}