Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 69 additions & 19 deletions rivet-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7352,6 +7352,7 @@ fn cmd_export(
homepage,
version_label,
versions_json,
sexpr_filter,
);
}

Expand Down Expand Up @@ -7979,6 +7980,7 @@ fn cmd_export_html(
_homepage: Option<&str>,
_version_label: Option<&str>,
_versions_json: Option<&str>,
sexpr_filter: Option<&str>,
) -> Result<bool> {
use crate::render::styles;
use crate::serve::components::ViewParams;
Expand Down Expand Up @@ -8079,7 +8081,14 @@ fn cmd_export_html(
.filter(|d| d.severity == rivet_core::schema::Severity::Error)
.count();

let wrap_page = |title: &str, content: &str| -> String {
let wrap_page = |title: &str, content: &str, rel_path: &str| -> String {
// REQ-088: depth-adjusted prefix so per-page asset and nav hrefs
// work for pages at any nesting (root, depth-1, depth-2, …).
// `rel_path` is the page's path relative to `out_dir`, e.g.
// "index.html" (depth 0) or "artifacts/foo.html" (depth 1) or
// "help/schema/foo.html" (depth 2).
let depth = rel_path.matches('/').count();
let prefix: String = "../".repeat(depth);
let version = env!("CARGO_PKG_VERSION");
let project_name = &state.context.project_name;
let error_badge = if error_count > 0 {
Expand Down Expand Up @@ -8111,7 +8120,7 @@ fn cmd_export_html(
.sum();
let stpa_nav = if stpa_count > 0 {
format!(
"<li><a href=\"stpa/index.html\">STPA \
"<li><a href=\"{prefix}stpa/index.html\">STPA \
<span class=\"nav-badge\">{stpa_count}</span></a></li>"
)
} else {
Expand All @@ -8128,7 +8137,7 @@ fn cmd_export_html(
} else {
String::new()
};
format!("<li><a href=\"../eu-ai-act/index.html\">EU AI Act{badge}</a></li>")
format!("<li><a href=\"{prefix}eu-ai-act/index.html\">EU AI Act{badge}</a></li>")
} else {
String::new()
};
Expand All @@ -8139,8 +8148,8 @@ fn cmd_export_html(
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title} — {project_name} — Rivet</title>
<style>{fonts_css}{css}</style>
<script>{mermaid_js}</script>
<link rel="stylesheet" href="{prefix}_assets/styles.css">
<script src="{prefix}_assets/mermaid.min.js"></script>
<script>
mermaid.initialize({{startOnLoad:false,theme:'neutral',securityLevel:'strict'}});
document.addEventListener('DOMContentLoaded',function(){{
Expand All @@ -8153,21 +8162,21 @@ document.addEventListener('DOMContentLoaded',function(){{
<nav role="navigation" aria-label="Main navigation">
<h1>Rivet</h1>
<ul>
<li><a href="../index.html">Overview
<li><a href="{prefix}index.html">Overview
<span class="nav-badge">{artifact_count}</span></a></li>
<li><a href="../artifacts/index.html">Artifacts
<li><a href="{prefix}artifacts/index.html">Artifacts
<span class="nav-badge">{artifact_count}</span></a></li>
<li><a href="../validate/index.html">Validation
<li><a href="{prefix}validate/index.html">Validation
{error_badge}</a></li>
<li class="nav-divider"></li>
<li><a href="../matrix/index.html">Matrix</a></li>
<li><a href="../coverage/index.html">Coverage</a></li>
<li><a href="../graph/index.html">Graph</a></li>
<li><a href="../documents/index.html">Documents{doc_badge}</a></li>
<li><a href="{prefix}matrix/index.html">Matrix</a></li>
<li><a href="{prefix}coverage/index.html">Coverage</a></li>
<li><a href="{prefix}graph/index.html">Graph</a></li>
<li><a href="{prefix}documents/index.html">Documents{doc_badge}</a></li>
<li class="nav-divider"></li>
{stpa_nav}
{eu_ai_act_nav}
<li><a href="../help/index.html">Help &amp; Docs</a></li>
<li><a href="{prefix}help/index.html">Help &amp; Docs</a></li>
</ul>
<div style="padding:.75rem 1rem;font-size:.7rem;color:var(--sidebar-text)">
v{version}
Expand All @@ -8176,21 +8185,35 @@ document.addEventListener('DOMContentLoaded',function(){{
<div class="content-area">
<main id="content" role="main">
{content}
<div class="footer">Generated by Rivet v{version} &mdash; <a href="index.html">Back to top</a></div>
<div class="footer">Generated by Rivet v{version} &mdash; <a href="{prefix}index.html">Back to top</a></div>
</main>
</div>
</div>
</body>
</html>"#,
fonts_css = styles::FONTS_CSS,
css = styles::CSS,
mermaid_js = MERMAID_JS,
)
};

let out_dir = output.unwrap_or(std::path::Path::new("dist"));
std::fs::create_dir_all(out_dir).with_context(|| format!("creating {}", out_dir.display()))?;

// REQ-088: write CSS + JS to a shared `_assets/` directory once,
// instead of inlining ~3MB of mermaid.min.js into every page. With
// 5000+ artifacts the previous per-page embed produced ~13 GB of
// mostly-identical bytes; this drops total export size by ~99%
// (4000 pages * 3MB inline -> 4000 pages of ~50KB markup + one
// 3MB shared asset).
let assets_dir = out_dir.join("_assets");
std::fs::create_dir_all(&assets_dir)
.with_context(|| format!("creating {}", assets_dir.display()))?;
std::fs::write(
assets_dir.join("styles.css"),
format!("{}{}", styles::FONTS_CSS, styles::CSS),
)
.with_context(|| format!("writing {}", assets_dir.join("styles.css").display()))?;
std::fs::write(assets_dir.join("mermaid.min.js"), MERMAID_JS)
.with_context(|| format!("writing {}", assets_dir.join("mermaid.min.js").display()))?;

let mut page_count = 0usize;

// Helper: render a page, wrap it, and write it to a relative path within out_dir.
Expand All @@ -8199,7 +8222,7 @@ document.addEventListener('DOMContentLoaded',function(){{
let write_page =
|rel_path: &str, page: &str, title: &str, out_dir: &std::path::Path| -> Result<()> {
let result = render::render_page(&ctx, page, &params);
let html = wrap_page(title, &result.html);
let html = wrap_page(title, &result.html, rel_path);
let dest = out_dir.join(rel_path);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)
Expand Down Expand Up @@ -8248,7 +8271,34 @@ document.addEventListener('DOMContentLoaded',function(){{
page_count += 1;

// ── Per-artifact detail pages ────────────────────────────────────
let artifact_ids: Vec<String> = state.store.iter().map(|a| a.id.clone()).collect();
//
// REQ-087: apply the s-expression `--filter` if provided so the
// per-artifact page set narrows to the requested subset. Previously
// `cmd_export` accepted `--filter` but never threaded it into
// `cmd_export_html`, so a filtered HTML export silently emitted
// every artifact — the F2 silent-failure class (flag claims a
// semantic the implementation does not deliver).
let artifact_ids: Vec<String> = if let Some(filter_src) = sexpr_filter {
let expr = rivet_core::sexpr_eval::parse_filter(filter_src).map_err(|errs| {
let msgs: Vec<String> = errs.iter().map(|e| e.to_string()).collect();
anyhow::anyhow!("invalid --filter expression: {}", msgs.join("; "))
})?;
state
.store
.iter()
.filter(|a| {
rivet_core::sexpr_eval::matches_filter_with_store(
&expr,
a,
&state.graph,
&state.store,
)
})
.map(|a| a.id.clone())
.collect()
} else {
state.store.iter().map(|a| a.id.clone()).collect()
};
for id in &artifact_ids {
let rel = format!("artifacts/{id}.html");
let page = format!("/artifacts/{id}");
Expand Down
162 changes: 162 additions & 0 deletions rivet-cli/tests/export_html.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// SAFETY-REVIEW (SCRC Phase 1, DD-058): Integration test code. Tests
// legitimately use unwrap/expect/panic/assert-indexing patterns because a
// test failure should panic with a clear stack. Same blanket allow as the
// other integration tests in this directory.
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::arithmetic_side_effects,
clippy::as_conversions,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::wildcard_enum_match_arm,
clippy::match_wildcard_for_single_variants,
clippy::panic,
clippy::todo,
clippy::unimplemented,
clippy::dbg_macro,
clippy::print_stdout,
clippy::print_stderr
)]

//! Integration tests for `rivet export --html`.
//!
//! These run the real binary against the rivet project itself (the dogfood
//! corpus) and assert on the structure / size of the output. The
//! assertions verify REQ-087 (`--filter` actually narrows the per-artifact
//! page set) and REQ-088 (CSS/JS extracted to shared `_assets/` rather
//! than embedded per-page; per-page payload stays small).
//!
//! rivet: verifies REQ-087
//! rivet: verifies REQ-088

use std::process::Command;

fn rivet_bin() -> std::path::PathBuf {
if let Ok(bin) = std::env::var("CARGO_BIN_EXE_rivet") {
return std::path::PathBuf::from(bin);
}
let manifest = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let workspace_root = manifest.parent().expect("workspace root");
workspace_root.join("target").join("debug").join("rivet")
}

fn project_root() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("workspace root")
.to_path_buf()
}

fn run_export(out: &std::path::Path, filter: Option<&str>) {
let mut cmd = Command::new(rivet_bin());
cmd.args([
"export",
"--format",
"html",
"--output",
out.to_str().unwrap(),
])
.current_dir(project_root());
if let Some(f) = filter {
cmd.arg("--filter").arg(f);
}
let status = cmd.status().expect("run rivet export");
assert!(status.success(), "rivet export exited non-zero: {status:?}");
}

fn count_html(dir: &std::path::Path) -> usize {
std::fs::read_dir(dir)
.map(|rd| {
rd.filter_map(Result::ok)
.filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("html"))
.count()
})
.unwrap_or(0)
}

/// REQ-088: CSS + JS are extracted to a single `_assets/` directory and
/// referenced by relative URL; per-page HTML stays small.
#[test]
fn export_html_extracts_shared_assets() {
let tmp = tempfile::tempdir().unwrap();
let out = tmp.path().join("dist");
run_export(&out, None);

let assets = out.join("_assets");
assert!(
assets.join("styles.css").is_file(),
"_assets/styles.css must exist"
);
assert!(
assets.join("mermaid.min.js").is_file(),
"_assets/mermaid.min.js must exist"
);

// Per-artifact pages must reference assets, not embed them.
let artifacts = out.join("artifacts");
let mut sample_pages = std::fs::read_dir(&artifacts)
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("html"))
.filter(|e| e.file_name() != *"index.html")
.take(5);
let first = sample_pages.next().expect("at least one artifact page");
let body = std::fs::read_to_string(first.path()).unwrap();
assert!(
body.contains("_assets/mermaid.min.js"),
"per-artifact page must reference shared mermaid.min.js, got:\n{}",
&body[..body.len().min(2000)]
);
// The page must not contain the verbatim mermaid library source (that
// signature only appears when the framework is inlined).
assert!(
!body.contains("function mermaid"),
"per-artifact page must not embed mermaid library source"
);
let len = body.len();
assert!(
len < 100_000,
"per-artifact page should be <100KB (was {len} bytes); REQ-088 budget"
);
}

/// REQ-087: `--filter` actually narrows the per-artifact page set.
///
/// Uses a deliberately unmatching filter so the assertion is independent
/// of the rivet corpus's current contents — what matters is that
/// "filter set" -> "fewer per-artifact pages than the unfiltered baseline."
#[test]
fn export_html_filter_narrows_per_artifact_pages() {
let tmp = tempfile::tempdir().unwrap();

// Baseline: no filter, full per-artifact set.
let full = tmp.path().join("full");
run_export(&full, None);
let full_count = count_html(&full.join("artifacts"));
assert!(
full_count > 10,
"baseline should emit many per-artifact pages, got {full_count}"
);

// Filtered: a clause that no artifact satisfies. The per-artifact
// page set should drop to (effectively) just artifacts/index.html.
let filtered = tmp.path().join("filtered");
run_export(
&filtered,
Some(r#"(has-tag "this-tag-definitely-does-not-exist-anywhere")"#),
);
let filtered_count = count_html(&filtered.join("artifacts"));
assert!(
filtered_count < full_count,
"filter must narrow: full={full_count}, filtered={filtered_count}"
);
// Allow the artifacts/index.html shell to remain — what we're
// proving is the per-artifact emission honoured the filter.
assert!(
filtered_count <= 2,
"an unmatching filter should leave at most the index shell; \
got {filtered_count} pages"
);
}
Loading