use std::path::Path;
use crate::project::Project;
#[derive(Debug, Clone, serde::Serialize)]
pub struct DocReport {
pub command: String,
pub ok: bool,
pub frontier_id: String,
pub out: String,
pub files_written: usize,
pub findings_documented: usize,
pub events_documented: usize,
}
pub fn write_site(project: &Project, out_dir: &Path) -> Result<DocReport, String> {
std::fs::create_dir_all(out_dir).map_err(|e| format!("create out dir: {e}"))?;
let findings_dir = out_dir.join("findings");
std::fs::create_dir_all(&findings_dir).map_err(|e| format!("create findings dir: {e}"))?;
let mut files_written = 0usize;
write_file(&out_dir.join("style.css"), &css())?;
files_written += 1;
write_file(&out_dir.join("index.html"), &render_index(project))?;
files_written += 1;
write_file(
&out_dir.join("findings.html"),
&render_findings_index(project),
)?;
files_written += 1;
for finding in &project.findings {
let path = findings_dir.join(format!("{}.html", finding.id));
write_file(&path, &render_finding_detail(project, finding))?;
files_written += 1;
}
write_file(&out_dir.join("events.html"), &render_events(project))?;
files_written += 1;
Ok(DocReport {
command: "doc".to_string(),
ok: true,
frontier_id: project
.frontier_id
.clone()
.unwrap_or_else(|| "(unset)".to_string()),
out: out_dir.display().to_string(),
files_written,
findings_documented: project.findings.len(),
events_documented: project.events.len(),
})
}
fn write_file(path: &Path, contents: &str) -> Result<(), String> {
std::fs::write(path, contents).map_err(|e| format!("write {}: {e}", path.display()))
}
fn esc(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
out
}
fn truncate(s: &str, n: usize) -> String {
if s.chars().count() <= n {
return s.to_string();
}
let mut out: String = s.chars().take(n).collect();
out.push('…');
out
}
fn page_shell(title: &str, body: &str, breadcrumbs: &str) -> String {
format!(
"<!doctype html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\">
<title>{title}</title>
<link rel=\"stylesheet\" href=\"{stylesheet}\">
</head>
<body>
<header>
<nav>{breadcrumbs}</nav>
</header>
<main>
{body}
</main>
<footer>
<p>generated by <code>vela doc</code></p>
</footer>
</body>
</html>",
title = esc(title),
stylesheet = if breadcrumbs.contains("href=\"../") {
"../style.css"
} else {
"style.css"
},
breadcrumbs = breadcrumbs,
body = body,
)
}
fn render_index(project: &Project) -> String {
let frontier_id = project
.frontier_id
.clone()
.unwrap_or_else(|| "(unset)".to_string());
let title = format!("{} · vela frontier", project.project.name);
let body = format!(
"<h1>{name}</h1>
<p class=\"id\">{frontier_id}</p>
<p class=\"description\">{description}</p>
<section>
<h2>Counts</h2>
<table class=\"counts\">
<tr><th>findings</th><td>{n_findings}</td></tr>
<tr><th>events</th><td>{n_events}</td></tr>
<tr><th>artifacts</th><td>{n_artifacts}</td></tr>
<tr><th>sources</th><td>{n_sources}</td></tr>
<tr><th>actors</th><td>{n_actors}</td></tr>
<tr><th>signatures</th><td>{n_signatures}</td></tr>
<tr><th>replications</th><td>{n_replications}</td></tr>
<tr><th>predictions</th><td>{n_predictions}</td></tr>
</table>
</section>
<section>
<h2>Pages</h2>
<ul>
<li><a href=\"findings.html\">Findings</a></li>
<li><a href=\"events.html\">Events</a></li>
</ul>
</section>",
name = esc(&project.project.name),
frontier_id = esc(&frontier_id),
description = esc(&project.project.description),
n_findings = project.findings.len(),
n_events = project.events.len(),
n_artifacts = project.artifacts.len(),
n_sources = project.sources.len(),
n_actors = project.actors.len(),
n_signatures = project.signatures.len(),
n_replications = project.replications.len(),
n_predictions = project.predictions.len(),
);
page_shell(&title, &body, "<a href=\"index.html\">home</a>")
}
fn render_findings_index(project: &Project) -> String {
let title = format!("{} · findings", project.project.name);
let mut rows = String::new();
for finding in &project.findings {
rows.push_str(&format!(
"<tr><td><a href=\"findings/{id}.html\"><code>{id}</code></a></td>
<td>{kind}</td>
<td>{status}</td>
<td>{conf}</td>
<td>{assertion}</td></tr>",
id = esc(&finding.id),
kind = esc(&finding.assertion.assertion_type),
status = esc(finding
.flags
.review_state
.as_ref()
.map(|s| match s {
crate::bundle::ReviewState::Accepted => "accepted",
crate::bundle::ReviewState::Contested => "contested",
crate::bundle::ReviewState::NeedsRevision => "needs_revision",
crate::bundle::ReviewState::Rejected => "rejected",
})
.unwrap_or("none")),
conf = format_args!("{:.2}", finding.confidence.score),
assertion = esc(&truncate(&finding.assertion.text, 140)),
));
}
let body = format!(
"<h1>Findings</h1>
<p>{n} findings on this frontier.</p>
<table class=\"findings\">
<thead><tr><th>id</th><th>type</th><th>status</th><th>confidence</th><th>assertion</th></tr></thead>
<tbody>{rows}</tbody>
</table>",
n = project.findings.len(),
rows = rows,
);
page_shell(
&title,
&body,
"<a href=\"index.html\">home</a> › findings",
)
}
fn render_finding_detail(project: &Project, finding: &crate::bundle::FindingBundle) -> String {
let title = format!("{} · {}", finding.id, project.project.name);
let related: Vec<&crate::events::StateEvent> = project
.events
.iter()
.filter(|e| e.target.id == finding.id)
.collect();
let mut events_rows = String::new();
for e in &related {
events_rows.push_str(&format!(
"<tr><td><code>{id}</code></td><td>{kind}</td><td>{actor}</td><td>{ts}</td><td>{reason}</td></tr>",
id = esc(&e.id),
kind = esc(&e.kind),
actor = esc(&e.actor.id),
ts = esc(&e.timestamp),
reason = esc(&truncate(&e.reason, 120)),
));
}
let signature_count = project
.signatures
.iter()
.filter(|s| s.finding_id == finding.id)
.count();
let body = format!(
"<h1>{id}</h1>
<p class=\"assertion\">{assertion}</p>
<section>
<h2>Metadata</h2>
<table class=\"metadata\">
<tr><th>type</th><td>{kind}</td></tr>
<tr><th>status</th><td>{status}</td></tr>
<tr><th>confidence</th><td>{conf}</td></tr>
<tr><th>basis</th><td>{basis}</td></tr>
<tr><th>retracted</th><td>{retracted}</td></tr>
<tr><th>contested</th><td>{contested}</td></tr>
<tr><th>signatures</th><td>{n_sigs}</td></tr>
<tr><th>annotations</th><td>{n_ann}</td></tr>
</table>
</section>
<section>
<h2>Provenance</h2>
<table class=\"provenance\">
<tr><th>source type</th><td>{src_type}</td></tr>
<tr><th>doi</th><td>{doi}</td></tr>
<tr><th>pmid</th><td>{pmid}</td></tr>
<tr><th>year</th><td>{year}</td></tr>
<tr><th>journal</th><td>{journal}</td></tr>
</table>
</section>
<section>
<h2>Events targeting this finding</h2>
<table class=\"events\">
<thead><tr><th>id</th><th>kind</th><th>actor</th><th>timestamp</th><th>reason</th></tr></thead>
<tbody>{events_rows}</tbody>
</table>
<p>{n_events} event(s).</p>
</section>",
id = esc(&finding.id),
assertion = esc(&finding.assertion.text),
kind = esc(&finding.assertion.assertion_type),
status = esc(finding
.flags
.review_state
.as_ref()
.map(|s| match s {
crate::bundle::ReviewState::Accepted => "accepted",
crate::bundle::ReviewState::Contested => "contested",
crate::bundle::ReviewState::NeedsRevision => "needs_revision",
crate::bundle::ReviewState::Rejected => "rejected",
})
.unwrap_or("none")),
conf = format_args!("{:.3}", finding.confidence.score),
basis = esc(&finding.confidence.basis),
retracted = finding.flags.retracted,
contested = finding.flags.contested,
n_sigs = signature_count,
n_ann = finding.annotations.len(),
src_type = esc(&finding.provenance.source_type),
doi = finding
.provenance
.doi
.as_deref()
.map(esc)
.unwrap_or_default(),
pmid = finding
.provenance
.pmid
.as_deref()
.map(esc)
.unwrap_or_default(),
year = finding
.provenance
.year
.map(|y| y.to_string())
.unwrap_or_default(),
journal = finding
.provenance
.journal
.as_deref()
.map(esc)
.unwrap_or_default(),
events_rows = events_rows,
n_events = related.len(),
);
page_shell(
&title,
&body,
&format!(
"<a href=\"../index.html\">home</a> › <a href=\"../findings.html\">findings</a> › <code>{}</code>",
esc(&finding.id)
),
)
}
fn render_events(project: &Project) -> String {
let title = format!("{} · events", project.project.name);
let mut rows = String::new();
for e in &project.events {
rows.push_str(&format!(
"<tr><td><code>{id}</code></td><td>{kind}</td><td>{actor}</td><td>{target_kind}/{target_id}</td><td>{ts}</td></tr>",
id = esc(&e.id),
kind = esc(&e.kind),
actor = esc(&e.actor.id),
target_kind = esc(&e.target.r#type),
target_id = esc(&e.target.id),
ts = esc(&e.timestamp),
));
}
let body = format!(
"<h1>Events</h1>
<p>{n} canonical events on this frontier, in append order.</p>
<table class=\"events\">
<thead><tr><th>id</th><th>kind</th><th>actor</th><th>target</th><th>timestamp</th></tr></thead>
<tbody>{rows}</tbody>
</table>",
n = project.events.len(),
rows = rows,
);
page_shell(
&title,
&body,
"<a href=\"index.html\">home</a> › events",
)
}
fn css() -> String {
String::from(
r#"/* vela doc minimal stylesheet */
:root {
--fg: #1a1a1a;
--bg: #fafafa;
--muted: #666;
--border: #ddd;
--accent: #c9a227;
}
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
font-size: 15px;
line-height: 1.5;
color: var(--fg);
background: var(--bg);
margin: 0;
padding: 0;
}
header { padding: 1rem 2rem; border-bottom: 1px solid var(--border); }
header nav { font-size: 13px; color: var(--muted); }
header nav a { color: var(--muted); text-decoration: none; }
header nav a:hover { color: var(--fg); text-decoration: underline; }
main { max-width: 980px; margin: 0 auto; padding: 2rem; }
footer { max-width: 980px; margin: 2rem auto; padding: 1rem 2rem; border-top: 1px solid var(--border); color: var(--muted); font-size: 13px; }
h1 { font-size: 24px; margin-top: 0; }
h2 { font-size: 18px; margin-top: 2rem; border-bottom: 1px solid var(--border); padding-bottom: 0.25rem; }
.id, .description { color: var(--muted); font-size: 13px; }
.assertion { font-size: 16px; padding: 1rem; background: white; border-left: 3px solid var(--accent); margin: 1rem 0; }
table { border-collapse: collapse; width: 100%; font-size: 13px; }
th, td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border); vertical-align: top; }
th { background: white; color: var(--muted); font-weight: 600; font-size: 12px; }
code { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 12px; }
a { color: var(--accent); }
table.counts th, table.metadata th, table.provenance th { width: 200px; }
section { margin-top: 1.5rem; }
"#,
)
}