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
91 changes: 91 additions & 0 deletions rivet-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,16 @@ enum Command {
file: PathBuf,
},

/// Resolve a computed embed and print the result
Embed {
/// Embed query string, e.g. "stats:types" or "coverage:rule-name"
query: String,

/// Output format: "html" or "text" (default)
#[arg(short, long, default_value = "text")]
format: String,
},

/// Start the language server (LSP over stdio)
Lsp,
}
Expand Down Expand Up @@ -902,6 +912,7 @@ fn run(cli: Cli) -> Result<bool> {
),
Command::Remove { id, force } => cmd_remove(&cli, id, *force),
Command::Batch { file } => cmd_batch(&cli, file),
Command::Embed { query, format } => cmd_embed(&cli, query, format),
}
}

Expand Down Expand Up @@ -2674,6 +2685,17 @@ fn cmd_export_html(
let ctx = state.as_render_context();
let params = ViewParams::default();

// SC-EMBED-1: warn when working tree is dirty.
if let Some(ref git) = state.context.git {
if git.is_dirty {
eprintln!(
"warning: working tree is dirty ({} uncommitted change{}) — exported data may not match any commit",
git.dirty_count,
if git.dirty_count == 1 { "" } else { "s" },
);
}
}

// ── Mermaid JS (inlined so the site works offline) ──────────────
const MERMAID_JS: &str = include_str!("../assets/mermaid.min.js");

Expand Down Expand Up @@ -5028,6 +5050,75 @@ fn cmd_batch(cli: &Cli, file: &std::path::Path) -> Result<bool> {
Ok(true)
}

fn cmd_embed(cli: &Cli, query: &str, format: &str) -> Result<bool> {
let schemas_dir = resolve_schemas_dir(cli);
let project_path = cli
.project
.canonicalize()
.unwrap_or_else(|_| cli.project.clone());

let state = crate::serve::reload_state(&project_path, &schemas_dir, 0)
.context("loading project for embed")?;

let request = rivet_core::embed::EmbedRequest::parse(query)
.map_err(|e| anyhow::anyhow!("{e}"))?;

let embed_ctx = rivet_core::embed::EmbedContext {
store: &state.store,
schema: &state.schema,
graph: &state.graph,
diagnostics: &state.cached_diagnostics,
};

match rivet_core::embed::resolve_embed(&request, &embed_ctx) {
Ok(html) => {
if format == "html" {
println!("{html}");
} else {
println!("{}", strip_html_tags(&html));
}
Ok(true)
}
Err(e) => {
eprintln!("{e}");
Ok(false)
}
}
}

/// Minimal HTML tag stripper for terminal-friendly output.
fn strip_html_tags(html: &str) -> String {
let mut result = String::with_capacity(html.len());
let mut in_tag = false;
let mut prev_was_newline = false;
for ch in html.chars() {
if ch == '<' {
in_tag = true;
continue;
}
if ch == '>' {
in_tag = false;
continue;
}
if !in_tag {
if ch == '\n' {
if !prev_was_newline {
result.push('\n');
prev_was_newline = true;
}
} else {
prev_was_newline = false;
result.push(ch);
}
}
}
result
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
}

fn cmd_lsp(cli: &Cli) -> Result<bool> {
use lsp_server::{Connection, Message, Response};
use lsp_types::*;
Expand Down
9 changes: 9 additions & 0 deletions rivet-cli/src/render/documents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,15 @@ pub(crate) fn render_document_detail(ctx: &RenderContext, id: &str) -> RenderRes
|aid| store.contains(aid),
|aid| crate::render::source::build_artifact_info(aid, store, graph),
|did| doc_store.get(did).is_some(),
|req| {
let embed_ctx = rivet_core::embed::EmbedContext {
store,
schema: ctx.schema,
graph,
diagnostics: ctx.diagnostics,
};
rivet_core::embed::resolve_embed(req, &embed_ctx).map_err(|e| e.to_string())
},
);
html.push_str(&body_html);
html.push_str("</div></div>");
Expand Down
1 change: 1 addition & 0 deletions rivet-cli/src/render/source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@ pub(crate) fn render_source_file_view(ctx: &RenderContext, raw_path: &str) -> St
|aid| store.contains(aid),
|aid| build_artifact_info(aid, store, graph),
|did| ctx.doc_store.get(did).is_some(),
|_req| Ok(String::new()),
);
let body_html = rewrite_image_paths(&body_html);
html.push_str(&body_html);
Expand Down
76 changes: 76 additions & 0 deletions rivet-cli/tests/cli_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -585,3 +585,79 @@ fn export_html_generates_static_site() {
let validate_path = out_dir.join("validate").join("index.html");
assert!(validate_path.exists(), "validate/index.html must exist");
}

// ── rivet embed ────────────────────────────────────────────────────────

/// `rivet embed "stats:types"` prints a stats table with type counts.
#[test]
fn embed_stats_types() {
let output = Command::new(rivet_bin())
.args([
"--project",
project_root().to_str().unwrap(),
"embed",
"stats:types",
])
.output()
.expect("failed to execute rivet embed stats:types");

assert!(
output.status.success(),
"rivet embed stats:types must exit 0. stderr: {}",
String::from_utf8_lossy(&output.stderr)
);

let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("Type") && stdout.contains("Count"),
"should contain a stats table header. Got: {stdout}"
);
}

/// `rivet embed "coverage"` prints coverage data or a no-rules message.
#[test]
fn embed_coverage() {
let output = Command::new(rivet_bin())
.args([
"--project",
project_root().to_str().unwrap(),
"embed",
"coverage",
])
.output()
.expect("failed to execute rivet embed coverage");

assert!(
output.status.success(),
"rivet embed coverage must exit 0. stderr: {}",
String::from_utf8_lossy(&output.stderr)
);

let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("Rule") || stdout.contains("No coverage"),
"should contain coverage output. Got: {stdout}"
);
}

/// `rivet embed "nonexistent"` reports an unknown embed error.
#[test]
fn embed_unknown_returns_error() {
let output = Command::new(rivet_bin())
.args([
"--project",
project_root().to_str().unwrap(),
"embed",
"nonexistent",
])
.output()
.expect("failed to execute rivet embed nonexistent");

let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let combined = format!("{stdout}{stderr}");
assert!(
combined.contains("Unknown embed") || combined.contains("unknown"),
"unknown embed should produce an error message. Got: {combined}"
);
}
37 changes: 37 additions & 0 deletions rivet-cli/tests/serve_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -636,3 +636,40 @@ fn artifact_detail_has_oembed_discovery_link() {
child.kill().ok();
child.wait().ok();
}

// ── Embed resolution in documents ──────────────────────────────────────

/// The documents page should not contain any embed-error spans for valid
/// embed types (stats, coverage). This verifies the embed resolver is
/// correctly wired in the serve pipeline.
#[test]
fn documents_page_has_no_embed_errors() {
let (mut child, port) = start_server();

let (status, body, _headers) = fetch(port, "/documents", false);
assert_eq!(status, 200, "documents page should load");
assert!(
!body.contains("embed-error"),
"no embed errors should appear on the documents list page"
);

child.kill().ok();
child.wait().ok();
}

/// The rivet embed CLI command should produce output consistent with
/// what the serve pipeline would render (minus HTML wrapper).
#[test]
fn embed_api_stats_endpoint() {
let (mut child, port) = start_server();

let (status, body, _headers) = fetch(port, "/api/v1/stats", false);
assert_eq!(status, 200, "/api/v1/stats should respond 200");
assert!(
body.contains("total") || body.contains("artifacts"),
"stats API should contain stats data"
);

child.kill().ok();
child.wait().ok();
}
Loading
Loading