1use crate::{Result, analyse, arxiv, provider, pwc, repos};
2use chrono::{DateTime, NaiveDate, Utc};
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Default, Serialize, Deserialize)]
6pub struct ReportProvenance {
7 pub generated_at: Option<DateTime<Utc>>,
8 pub operation: Option<OperationKind>,
9 #[serde(default)]
10 pub selectors: ReportSelectors,
11 pub limit: Option<usize>,
12 pub paper_source: Option<String>,
13 pub repo_source: Option<String>,
14}
15
16#[derive(Debug, Default, Serialize, Deserialize)]
17pub struct ReportSelectors {
18 pub query: Option<String>,
19 pub category: Option<String>,
20 pub since: Option<String>,
21 pub from: Option<NaiveDate>,
22 pub to: Option<NaiveDate>,
23 pub arxiv: Option<String>,
24}
25
26#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
27#[serde(rename_all = "snake_case")]
28pub enum OperationKind {
29 Scan,
30 Inspect,
31}
32
33impl OperationKind {
34 fn as_str(self) -> &'static str {
35 match self {
36 Self::Scan => "scan",
37 Self::Inspect => "inspect",
38 }
39 }
40}
41
42#[derive(Debug, Serialize, Deserialize)]
43pub struct ScanReport {
44 #[serde(default)]
45 pub schema_version: u32,
46 #[serde(default)]
47 pub provenance: ReportProvenance,
48 #[serde(default)]
49 pub paper_provider_outcomes: Vec<provider::ProviderOutcome>,
50 pub paper_results: Vec<PaperResult>,
51}
52
53#[derive(Debug, Serialize, Deserialize)]
54pub struct PaperResult {
55 pub paper: arxiv::Paper,
56 pub code_links: Vec<pwc::CodeLink>,
57 pub repositories: Vec<repos::Repository>,
58 #[serde(default)]
59 pub repository_matches: Vec<repos::RepositoryMatch>,
60 pub repository_analyses: Vec<analyse::RepositoryAnalysis>,
61 #[serde(default = "provider::default_success")]
62 pub pwc_outcome: provider::ProviderOutcome,
63 #[serde(default = "provider::default_success")]
64 pub repository_search_outcome: provider::ProviderOutcome,
65 #[serde(default)]
66 pub repository_inspection_outcomes: Vec<provider::ProviderOutcome>,
67 pub gap_assessment: analyse::GapAssessment,
68}
69
70#[derive(Clone, Copy)]
71pub enum OutputFormat {
72 Markdown,
73 Json,
74}
75
76pub fn render(report: &ScanReport, format: OutputFormat) -> Result<String> {
77 Ok(match format {
78 OutputFormat::Json => serde_json::to_string_pretty(report)?,
79 OutputFormat::Markdown => markdown(report),
80 })
81}
82
83fn markdown(report: &ScanReport) -> String {
84 let mut results: Vec<_> = report.paper_results.iter().collect();
85 results.sort_by_key(|result| std::cmp::Reverse(result.gap_assessment.implementation_gap_score));
86
87 let mut out = String::from("# paper-gap report\n\n");
88 push_provenance(&mut out, report);
89 out.push_str(&format!(
90 "{} papers analysed. Ranked by implementation gap score.\n\n",
91 results.len()
92 ));
93 for outcome in report
94 .paper_provider_outcomes
95 .iter()
96 .filter(|outcome| !outcome.succeeded())
97 {
98 out.push_str(&format!(
99 "- Paper provider warning: {} {}{}\n",
100 outcome.provider,
101 outcome.status,
102 outcome
103 .message
104 .as_deref()
105 .map(|message| format!(" — {message}"))
106 .unwrap_or_default()
107 ));
108 }
109 if report
110 .paper_provider_outcomes
111 .iter()
112 .any(|outcome| !outcome.succeeded())
113 {
114 out.push('\n');
115 }
116
117 for (index, result) in results.iter().enumerate() {
118 let p = &result.paper;
119 out.push_str(&format!(
120 "## {}. {}\n\n- Implementation gap score: {}\n- arXiv: {}\n- Published: {}\n- Authors: {}\n- Categories: {}\n",
121 index + 1,
122 p.title,
123 result.gap_assessment.implementation_gap_score,
124 p.arxiv_id.as_deref().unwrap_or(""),
125 p.published_date.as_deref().unwrap_or("unknown"),
126 p.authors.join(", "),
127 p.categories.join(", ")
128 ));
129
130 push_code_links(&mut out, &result.code_links);
131 push_repositories(
132 &mut out,
133 &result.repositories,
134 &result.repository_matches,
135 &result.repository_analyses,
136 );
137 push_provider_warnings(&mut out, result);
138 push_score(&mut out, &result.gap_assessment);
139
140 out.push_str(&format!("\n{}\n\n", p.abstract_text));
141 }
142
143 out
144}
145
146fn push_provenance(out: &mut String, report: &ScanReport) {
147 if report.schema_version > 0 {
148 out.push_str(&format!("- Schema version: {}\n", report.schema_version));
149 }
150 let provenance = &report.provenance;
151 if let Some(generated_at) = provenance.generated_at {
152 out.push_str(&format!("- Generated: {}\n", generated_at.to_rfc3339()));
153 }
154 if let Some(operation) = provenance.operation {
155 out.push_str(&format!("- Operation: {}\n", operation.as_str()));
156 }
157 let selectors = &provenance.selectors;
158 let values = [
159 ("query", selectors.query.as_deref().map(str::to_string)),
160 (
161 "category",
162 selectors.category.as_deref().map(str::to_string),
163 ),
164 ("since", selectors.since.as_deref().map(str::to_string)),
165 ("from", selectors.from.map(|value| value.to_string())),
166 ("to", selectors.to.map(|value| value.to_string())),
167 ("arxiv", selectors.arxiv.as_deref().map(str::to_string)),
168 ]
169 .into_iter()
170 .filter_map(|(name, value)| value.map(|value| format!("{name}={value}")))
171 .collect::<Vec<_>>();
172 if !values.is_empty() {
173 out.push_str(&format!("- Selectors: {}\n", values.join(", ")));
174 }
175 if let Some(limit) = provenance.limit {
176 out.push_str(&format!("- Limit: {limit}\n"));
177 }
178 if let Some(source) = &provenance.paper_source {
179 out.push_str(&format!("- Paper source: {source}\n"));
180 }
181 if let Some(source) = &provenance.repo_source {
182 out.push_str(&format!("- Repository source: {source}\n"));
183 }
184 if report.schema_version > 0 || provenance.operation.is_some() {
185 out.push('\n');
186 }
187}
188
189fn push_code_links(out: &mut String, links: &[pwc::CodeLink]) {
190 if links.is_empty() {
191 out.push_str("- Papers with Code links: none known\n");
192 return;
193 }
194 out.push_str("- Papers with Code links:\n");
195 for link in links {
196 out.push_str(&format!(" - {}\n", link.repository_url));
197 }
198}
199
200fn push_repositories(
201 out: &mut String,
202 repositories: &[repos::Repository],
203 matches: &[repos::RepositoryMatch],
204 analyses: &[analyse::RepositoryAnalysis],
205) {
206 if repositories.is_empty() {
207 out.push_str(
208 "- Repository search results: none found
209",
210 );
211 return;
212 }
213 out.push_str(
214 "- Repository search results:
215",
216 );
217 for (index, repo) in repositories.iter().enumerate() {
218 let signals = analyses
219 .get(index)
220 .map(quality_signals)
221 .unwrap_or_else(|| vec!["signals: none".to_string()]);
222 let match_summary = matches
223 .iter()
224 .find(|matched| matched.repository.url == repo.url)
225 .map(|matched| {
226 let evidence = matched
227 .evidence
228 .iter()
229 .map(|item| format!("{}={} (+{})", item.kind, item.value, item.points))
230 .collect::<Vec<_>>()
231 .join("; ");
232 format!("match {} [{}]", matched.confidence_score, evidence)
233 })
234 .unwrap_or_else(|| "match unknown".into());
235 out.push_str(&format!(
236 " - {} ({}) — {} — {}
237",
238 repo.url,
239 repo.forge,
240 match_summary,
241 signals.join(", ")
242 ));
243 }
244}
245
246fn quality_signals(analysis: &analyse::RepositoryAnalysis) -> Vec<String> {
247 let mut signals = Vec::new();
248 if analysis.has_readme {
249 signals.push("README");
250 }
251 if analysis.has_license {
252 signals.push("license");
253 }
254 if analysis.has_tests {
255 signals.push("tests");
256 }
257 if analysis.has_ci {
258 signals.push("CI");
259 }
260 if analysis.has_docs {
261 signals.push("docs");
262 }
263 if analysis.has_examples {
264 signals.push("examples");
265 }
266 if analysis.has_package_manifest {
267 signals.push("package");
268 }
269 if analysis.has_reproducibility_scripts {
270 signals.push("repro");
271 }
272 if analysis.has_benchmarks {
273 signals.push("benchmarks");
274 }
275 if analysis.is_notebook_only {
276 signals.push("notebook-only");
277 }
278 if analysis.is_stale {
279 signals.push("stale");
280 }
281 if analysis.is_archived {
282 signals.push("archived");
283 }
284 if signals.is_empty() {
285 vec!["signals: none".to_string()]
286 } else {
287 signals.into_iter().map(str::to_string).collect()
288 }
289}
290
291fn push_provider_warnings(out: &mut String, result: &PaperResult) {
292 let outcomes = std::iter::once(&result.pwc_outcome)
293 .chain(std::iter::once(&result.repository_search_outcome))
294 .chain(result.repository_inspection_outcomes.iter());
295 for outcome in outcomes.filter(|outcome| !outcome.succeeded()) {
296 out.push_str(&format!(
297 "- Provider warning: {} {}{}\n",
298 outcome.provider,
299 outcome.status,
300 outcome
301 .message
302 .as_deref()
303 .map(|message| format!(" — {message}"))
304 .unwrap_or_default()
305 ));
306 }
307}
308
309fn push_score(out: &mut String, assessment: &analyse::GapAssessment) {
310 let c = &assessment.components;
311 out.push_str("- Score components:\n");
312 out.push_str(&format!(" - no_code_score: {}\n", c.no_code_score));
313 out.push_str(&format!(
314 " - prototype_only_score: {}\n",
315 c.prototype_only_score
316 ));
317 out.push_str(&format!(" - stale_repo_score: {}\n", c.stale_repo_score));
318 out.push_str(&format!(
319 " - packaging_gap_score: {}\n",
320 c.packaging_gap_score
321 ));
322 out.push_str(&format!(" - docs_gap_score: {}\n", c.docs_gap_score));
323 out.push_str(&format!(" - tests_gap_score: {}\n", c.tests_gap_score));
324 out.push_str(&format!(" - license_gap_score: {}\n", c.license_gap_score));
325 out.push_str(&format!(
326 " - reproducibility_gap_score: {}\n",
327 c.reproducibility_gap_score
328 ));
329 out.push_str(&format!(
330 " - ecosystem_gap_score: {}\n",
331 c.ecosystem_gap_score
332 ));
333
334 if assessment.gaps.is_empty() {
335 out.push_str("- Gap reasons: none\n");
336 return;
337 }
338 out.push_str("- Gap reasons:\n");
339 for gap in &assessment.gaps {
340 out.push_str(&format!(
341 " - {:?}: +{} — {}\n",
342 gap.gap_type, gap.points, gap.explanation
343 ));
344 }
345}