1use std::path::Path;
23
24use crate::project::Project;
25
26#[derive(Debug, Clone, serde::Serialize)]
28pub struct DocReport {
29 pub command: String,
30 pub ok: bool,
31 pub frontier_id: String,
32 pub out: String,
33 pub files_written: usize,
34 pub findings_documented: usize,
35 pub events_documented: usize,
36}
37
38pub fn write_site(project: &Project, out_dir: &Path) -> Result<DocReport, String> {
41 std::fs::create_dir_all(out_dir).map_err(|e| format!("create out dir: {e}"))?;
42 let findings_dir = out_dir.join("findings");
43 std::fs::create_dir_all(&findings_dir).map_err(|e| format!("create findings dir: {e}"))?;
44
45 let mut files_written = 0usize;
46
47 write_file(&out_dir.join("style.css"), &css())?;
48 files_written += 1;
49
50 write_file(&out_dir.join("index.html"), &render_index(project))?;
51 files_written += 1;
52
53 write_file(
54 &out_dir.join("findings.html"),
55 &render_findings_index(project),
56 )?;
57 files_written += 1;
58
59 for finding in &project.findings {
60 let path = findings_dir.join(format!("{}.html", finding.id));
61 write_file(&path, &render_finding_detail(project, finding))?;
62 files_written += 1;
63 }
64
65 write_file(&out_dir.join("events.html"), &render_events(project))?;
66 files_written += 1;
67
68 Ok(DocReport {
69 command: "doc".to_string(),
70 ok: true,
71 frontier_id: project
72 .frontier_id
73 .clone()
74 .unwrap_or_else(|| "(unset)".to_string()),
75 out: out_dir.display().to_string(),
76 files_written,
77 findings_documented: project.findings.len(),
78 events_documented: project.events.len(),
79 })
80}
81
82fn write_file(path: &Path, contents: &str) -> Result<(), String> {
83 std::fs::write(path, contents).map_err(|e| format!("write {}: {e}", path.display()))
84}
85
86fn esc(s: &str) -> String {
89 let mut out = String::with_capacity(s.len());
90 for c in s.chars() {
91 match c {
92 '&' => out.push_str("&"),
93 '<' => out.push_str("<"),
94 '>' => out.push_str(">"),
95 '"' => out.push_str("""),
96 '\'' => out.push_str("'"),
97 _ => out.push(c),
98 }
99 }
100 out
101}
102
103fn truncate(s: &str, n: usize) -> String {
106 if s.chars().count() <= n {
107 return s.to_string();
108 }
109 let mut out: String = s.chars().take(n).collect();
110 out.push('…');
111 out
112}
113
114fn page_shell(title: &str, body: &str, breadcrumbs: &str) -> String {
115 format!(
116 "<!doctype html>
117<html lang=\"en\">
118<head>
119<meta charset=\"utf-8\">
120<title>{title}</title>
121<link rel=\"stylesheet\" href=\"{stylesheet}\">
122</head>
123<body>
124<header>
125<nav>{breadcrumbs}</nav>
126</header>
127<main>
128{body}
129</main>
130<footer>
131<p>generated by <code>vela doc</code></p>
132</footer>
133</body>
134</html>",
135 title = esc(title),
136 stylesheet = if breadcrumbs.contains("href=\"../") {
137 "../style.css"
138 } else {
139 "style.css"
140 },
141 breadcrumbs = breadcrumbs,
142 body = body,
143 )
144}
145
146fn render_index(project: &Project) -> String {
147 let frontier_id = project
148 .frontier_id
149 .clone()
150 .unwrap_or_else(|| "(unset)".to_string());
151 let title = format!("{} · vela frontier", project.project.name);
152 let body = format!(
153 "<h1>{name}</h1>
154<p class=\"id\">{frontier_id}</p>
155<p class=\"description\">{description}</p>
156
157<section>
158<h2>Counts</h2>
159<table class=\"counts\">
160<tr><th>findings</th><td>{n_findings}</td></tr>
161<tr><th>events</th><td>{n_events}</td></tr>
162<tr><th>artifacts</th><td>{n_artifacts}</td></tr>
163<tr><th>sources</th><td>{n_sources}</td></tr>
164<tr><th>actors</th><td>{n_actors}</td></tr>
165<tr><th>signatures</th><td>{n_signatures}</td></tr>
166<tr><th>replications</th><td>{n_replications}</td></tr>
167<tr><th>predictions</th><td>{n_predictions}</td></tr>
168</table>
169</section>
170
171<section>
172<h2>Pages</h2>
173<ul>
174<li><a href=\"findings.html\">Findings</a></li>
175<li><a href=\"events.html\">Events</a></li>
176</ul>
177</section>",
178 name = esc(&project.project.name),
179 frontier_id = esc(&frontier_id),
180 description = esc(&project.project.description),
181 n_findings = project.findings.len(),
182 n_events = project.events.len(),
183 n_artifacts = project.artifacts.len(),
184 n_sources = project.sources.len(),
185 n_actors = project.actors.len(),
186 n_signatures = project.signatures.len(),
187 n_replications = project.replications.len(),
188 n_predictions = project.predictions.len(),
189 );
190 page_shell(&title, &body, "<a href=\"index.html\">home</a>")
191}
192
193fn render_findings_index(project: &Project) -> String {
194 let title = format!("{} · findings", project.project.name);
195 let mut rows = String::new();
196 for finding in &project.findings {
197 rows.push_str(&format!(
198 "<tr><td><a href=\"findings/{id}.html\"><code>{id}</code></a></td>
199<td>{kind}</td>
200<td>{status}</td>
201<td>{conf}</td>
202<td>{assertion}</td></tr>",
203 id = esc(&finding.id),
204 kind = esc(&finding.assertion.assertion_type),
205 status = esc(finding
206 .flags
207 .review_state
208 .as_ref()
209 .map(|s| match s {
210 crate::bundle::ReviewState::Accepted => "accepted",
211 crate::bundle::ReviewState::Contested => "contested",
212 crate::bundle::ReviewState::NeedsRevision => "needs_revision",
213 crate::bundle::ReviewState::Rejected => "rejected",
214 })
215 .unwrap_or("none")),
216 conf = format_args!("{:.2}", finding.confidence.score),
217 assertion = esc(&truncate(&finding.assertion.text, 140)),
218 ));
219 }
220 let body = format!(
221 "<h1>Findings</h1>
222<p>{n} findings on this frontier.</p>
223<table class=\"findings\">
224<thead><tr><th>id</th><th>type</th><th>status</th><th>confidence</th><th>assertion</th></tr></thead>
225<tbody>{rows}</tbody>
226</table>",
227 n = project.findings.len(),
228 rows = rows,
229 );
230 page_shell(
231 &title,
232 &body,
233 "<a href=\"index.html\">home</a> › findings",
234 )
235}
236
237fn render_finding_detail(project: &Project, finding: &crate::bundle::FindingBundle) -> String {
238 let title = format!("{} · {}", finding.id, project.project.name);
239 let related: Vec<&crate::events::StateEvent> = project
240 .events
241 .iter()
242 .filter(|e| e.target.id == finding.id)
243 .collect();
244 let mut events_rows = String::new();
245 for e in &related {
246 events_rows.push_str(&format!(
247 "<tr><td><code>{id}</code></td><td>{kind}</td><td>{actor}</td><td>{ts}</td><td>{reason}</td></tr>",
248 id = esc(&e.id),
249 kind = esc(&e.kind),
250 actor = esc(&e.actor.id),
251 ts = esc(&e.timestamp),
252 reason = esc(&truncate(&e.reason, 120)),
253 ));
254 }
255 let signature_count = project
256 .signatures
257 .iter()
258 .filter(|s| s.finding_id == finding.id)
259 .count();
260 let body = format!(
261 "<h1>{id}</h1>
262<p class=\"assertion\">{assertion}</p>
263
264<section>
265<h2>Metadata</h2>
266<table class=\"metadata\">
267<tr><th>type</th><td>{kind}</td></tr>
268<tr><th>status</th><td>{status}</td></tr>
269<tr><th>confidence</th><td>{conf}</td></tr>
270<tr><th>basis</th><td>{basis}</td></tr>
271<tr><th>retracted</th><td>{retracted}</td></tr>
272<tr><th>contested</th><td>{contested}</td></tr>
273<tr><th>signatures</th><td>{n_sigs}</td></tr>
274<tr><th>annotations</th><td>{n_ann}</td></tr>
275</table>
276</section>
277
278<section>
279<h2>Provenance</h2>
280<table class=\"provenance\">
281<tr><th>source type</th><td>{src_type}</td></tr>
282<tr><th>doi</th><td>{doi}</td></tr>
283<tr><th>pmid</th><td>{pmid}</td></tr>
284<tr><th>year</th><td>{year}</td></tr>
285<tr><th>journal</th><td>{journal}</td></tr>
286</table>
287</section>
288
289<section>
290<h2>Events targeting this finding</h2>
291<table class=\"events\">
292<thead><tr><th>id</th><th>kind</th><th>actor</th><th>timestamp</th><th>reason</th></tr></thead>
293<tbody>{events_rows}</tbody>
294</table>
295<p>{n_events} event(s).</p>
296</section>",
297 id = esc(&finding.id),
298 assertion = esc(&finding.assertion.text),
299 kind = esc(&finding.assertion.assertion_type),
300 status = esc(finding
301 .flags
302 .review_state
303 .as_ref()
304 .map(|s| match s {
305 crate::bundle::ReviewState::Accepted => "accepted",
306 crate::bundle::ReviewState::Contested => "contested",
307 crate::bundle::ReviewState::NeedsRevision => "needs_revision",
308 crate::bundle::ReviewState::Rejected => "rejected",
309 })
310 .unwrap_or("none")),
311 conf = format_args!("{:.3}", finding.confidence.score),
312 basis = esc(&finding.confidence.basis),
313 retracted = finding.flags.retracted,
314 contested = finding.flags.contested,
315 n_sigs = signature_count,
316 n_ann = finding.annotations.len(),
317 src_type = esc(&finding.provenance.source_type),
318 doi = finding
319 .provenance
320 .doi
321 .as_deref()
322 .map(esc)
323 .unwrap_or_default(),
324 pmid = finding
325 .provenance
326 .pmid
327 .as_deref()
328 .map(esc)
329 .unwrap_or_default(),
330 year = finding
331 .provenance
332 .year
333 .map(|y| y.to_string())
334 .unwrap_or_default(),
335 journal = finding
336 .provenance
337 .journal
338 .as_deref()
339 .map(esc)
340 .unwrap_or_default(),
341 events_rows = events_rows,
342 n_events = related.len(),
343 );
344 page_shell(
345 &title,
346 &body,
347 &format!(
348 "<a href=\"../index.html\">home</a> › <a href=\"../findings.html\">findings</a> › <code>{}</code>",
349 esc(&finding.id)
350 ),
351 )
352}
353
354fn render_events(project: &Project) -> String {
355 let title = format!("{} · events", project.project.name);
356 let mut rows = String::new();
357 for e in &project.events {
358 rows.push_str(&format!(
359 "<tr><td><code>{id}</code></td><td>{kind}</td><td>{actor}</td><td>{target_kind}/{target_id}</td><td>{ts}</td></tr>",
360 id = esc(&e.id),
361 kind = esc(&e.kind),
362 actor = esc(&e.actor.id),
363 target_kind = esc(&e.target.r#type),
364 target_id = esc(&e.target.id),
365 ts = esc(&e.timestamp),
366 ));
367 }
368 let body = format!(
369 "<h1>Events</h1>
370<p>{n} canonical events on this frontier, in append order.</p>
371<table class=\"events\">
372<thead><tr><th>id</th><th>kind</th><th>actor</th><th>target</th><th>timestamp</th></tr></thead>
373<tbody>{rows}</tbody>
374</table>",
375 n = project.events.len(),
376 rows = rows,
377 );
378 page_shell(
379 &title,
380 &body,
381 "<a href=\"index.html\">home</a> › events",
382 )
383}
384
385fn css() -> String {
386 String::from(
387 r#"/* vela doc minimal stylesheet */
388:root {
389 --fg: #1a1a1a;
390 --bg: #fafafa;
391 --muted: #666;
392 --border: #ddd;
393 --accent: #c9a227;
394}
395* { box-sizing: border-box; }
396body {
397 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
398 font-size: 15px;
399 line-height: 1.5;
400 color: var(--fg);
401 background: var(--bg);
402 margin: 0;
403 padding: 0;
404}
405header { padding: 1rem 2rem; border-bottom: 1px solid var(--border); }
406header nav { font-size: 13px; color: var(--muted); }
407header nav a { color: var(--muted); text-decoration: none; }
408header nav a:hover { color: var(--fg); text-decoration: underline; }
409main { max-width: 980px; margin: 0 auto; padding: 2rem; }
410footer { max-width: 980px; margin: 2rem auto; padding: 1rem 2rem; border-top: 1px solid var(--border); color: var(--muted); font-size: 13px; }
411h1 { font-size: 24px; margin-top: 0; }
412h2 { font-size: 18px; margin-top: 2rem; border-bottom: 1px solid var(--border); padding-bottom: 0.25rem; }
413.id, .description { color: var(--muted); font-size: 13px; }
414.assertion { font-size: 16px; padding: 1rem; background: white; border-left: 3px solid var(--accent); margin: 1rem 0; }
415table { border-collapse: collapse; width: 100%; font-size: 13px; }
416th, td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border); vertical-align: top; }
417th { background: white; color: var(--muted); font-weight: 600; font-size: 12px; }
418code { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 12px; }
419a { color: var(--accent); }
420table.counts th, table.metadata th, table.provenance th { width: 200px; }
421section { margin-top: 1.5rem; }
422"#,
423 )
424}