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}