diff --git a/artifacts/architecture.yaml b/artifacts/architecture.yaml index 7f8bfee..96aa465 100644 --- a/artifacts/architecture.yaml +++ b/artifacts/architecture.yaml @@ -48,10 +48,17 @@ artifacts: - id: ARCH-CORE-001 type: aadl-component title: RivetCore process - description: > + description: | Core library process containing all domain logic: config loading, schema merging, artifact storage, adapter dispatch, graph building, validation, matrix computation, diff, documents, and query. + + ```mermaid + flowchart LR + Config --> Store + Store --> Graph + Graph --> Validate + ``` status: implemented tags: [aadl, architecture, core] links: diff --git a/rivet-cli/src/docs.rs b/rivet-cli/src/docs.rs index d4873ad..10c2315 100644 --- a/rivet-cli/src/docs.rs +++ b/rivet-cli/src/docs.rs @@ -996,6 +996,68 @@ pub fn list_topics(format: &str) -> String { out } +/// List every registered computed embed token. +/// +/// Sourced from `rivet_core::embed::EMBED_REGISTRY` so the listing never +/// drifts from what `resolve_embed` actually dispatches. +pub fn list_embeds(format: &str) -> String { + let specs = rivet_core::embed::registry(); + + if format == "json" { + let items: Vec = specs + .iter() + .map(|s| { + serde_json::json!({ + "name": s.name, + "args": s.args, + "summary": s.summary, + "example": s.example, + "legacy": s.legacy, + }) + }) + .collect(); + return serde_json::to_string_pretty(&serde_json::json!({ + "command": "docs-embeds", + "embeds": items, + })) + .unwrap_or_default(); + } + + // Plain-text: aligned columns with a short footer pointing to the + // full syntax reference and to `rivet embed` for CLI rendering. + let name_w = specs.iter().map(|s| s.name.len()).max().unwrap_or(4); + let args_w = specs.iter().map(|s| s.args.len()).max().unwrap_or(6); + let mut out = String::new(); + out.push_str("Registered computed embeds:\n\n"); + out.push_str(&format!( + " {:[:args] Render any embed from the CLI\n"); + out.push_str(" rivet docs embed-syntax Full {{...}} syntax reference\n"); + out +} + /// Show a specific topic. pub fn show_topic(slug: &str, format: &str) -> String { let Some(topic) = TOPICS.iter().find(|t| t.slug == slug) else { diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index 6623c83..f4354bd 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -699,6 +699,25 @@ enum Command { format: String, }, + /// Run an s-expression filter against the project and print matches + /// + /// Mirror of the MCP `rivet_query` tool and the `{{query:(...)}}` + /// document embed — all three share `rivet_core::query::execute_sexpr` + /// so their results agree for the same filter. + Query { + /// The s-expression filter (e.g. '(and (= type "requirement") (has-tag "stpa"))') + #[arg(long)] + sexpr: String, + + /// Maximum number of results (default: 100) + #[arg(long, default_value = "100")] + limit: usize, + + /// Output format: "text" (default), "json", "ids" + #[arg(short, long, default_value = "text")] + format: String, + }, + /// Stamp artifact(s) with AI provenance metadata Stamp { /// Artifact ID to stamp (or "all" for all artifacts in a file) @@ -1214,6 +1233,11 @@ fn run(cli: Cli) -> Result { 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), + Command::Query { + sexpr, + limit, + format, + } => cmd_query(&cli, sexpr, *limit, format), Command::Stamp { id, created_by, @@ -5949,7 +5973,15 @@ fn cmd_docs( } else if let Some(pattern) = grep { print!("{}", docs::grep_docs(pattern, format, context)); } else if let Some(slug) = topic { - print!("{}", docs::show_topic(slug, format)); + // Special meta-topic: `rivet docs embeds` lists every registered + // {{...}} token from `rivet_core::embed::EMBED_REGISTRY`. Kept in + // `docs` (rather than a new top-level subcommand) so it ships in + // the existing --help tree and stays near `docs embed-syntax`. + if slug == "embeds" { + print!("{}", docs::list_embeds(format)); + } else { + print!("{}", docs::show_topic(slug, format)); + } } else { print!("{}", docs::list_topics(format)); } @@ -8757,6 +8789,92 @@ fn cmd_mcp(cli: &Cli) -> Result { Ok(true) } +/// `rivet query --sexpr "..."` — run an s-expression filter from the CLI. +/// +/// Thin adapter over `rivet_core::query::execute_sexpr`, the same entry +/// point used by MCP's `rivet_query` tool and the `{{query:(...)}}` embed, +/// so all three surfaces emit the same match set for the same filter. +/// Three output formats: `text` (one line per match, id + title + status), +/// `json` (MCP-shape: `{filter, count, total, truncated, artifacts[]}`), +/// or `ids` (newline-separated IDs — handy for shell pipelines). +fn cmd_query(cli: &Cli, sexpr: &str, limit: usize, format: &str) -> Result { + validate_format(format, &["text", "json", "ids"])?; + + let project = rivet_core::load_project_full(&cli.project) + .with_context(|| format!("loading project from {}", cli.project.display()))?; + + let result = + rivet_core::query::execute_sexpr(&project.store, &project.graph, sexpr, Some(limit)) + .map_err(|e| anyhow::anyhow!("{e}"))?; + + match format { + "json" => { + let artifacts: Vec = result + .matches + .iter() + .map(|a| { + let links: Vec = a + .links + .iter() + .map(|l| { + serde_json::json!({"type": l.link_type, "target": l.target}) + }) + .collect(); + serde_json::json!({ + "id": a.id, + "type": a.artifact_type, + "title": a.title, + "status": a.status.as_deref().unwrap_or("-"), + "tags": a.tags, + "links": links, + "description": a.description.as_deref().unwrap_or(""), + }) + }) + .collect(); + let out = serde_json::json!({ + "filter": sexpr, + "count": artifacts.len(), + "total": result.total, + "truncated": result.truncated, + "artifacts": artifacts, + }); + println!("{}", serde_json::to_string_pretty(&out)?); + } + "ids" => { + for a in &result.matches { + println!("{}", a.id); + } + } + _ => { + // text + if result.matches.is_empty() { + println!("No artifacts match: {sexpr}"); + } else { + for a in &result.matches { + println!( + "{:16} {:16} {:8} {}", + a.id, + a.artifact_type, + a.status.as_deref().unwrap_or("-"), + a.title, + ); + } + if result.truncated { + println!( + "\n{} result(s) shown, {} match total — raise --limit to see more.", + result.matches.len(), + result.total, + ); + } else { + println!("\n{} result(s).", result.matches.len()); + } + } + } + } + + Ok(true) +} + fn cmd_lsp(cli: &Cli) -> Result { use lsp_server::{Connection, Message, Response}; use lsp_types::*; diff --git a/rivet-cli/src/mcp.rs b/rivet-cli/src/mcp.rs index 4a30785..260bdb1 100644 --- a/rivet-cli/src/mcp.rs +++ b/rivet-cli/src/mcp.rs @@ -943,46 +943,43 @@ fn json_to_yaml_value(v: &Value) -> serde_yaml::Value { // ── Query tool helper ───────────────────────────────────────────────── fn tool_query(proj: &McpProject, params: &QueryParams) -> Result { - let expr = rivet_core::sexpr_eval::parse_filter(¶ms.filter).map_err(|errs| { - let msgs: Vec = errs.iter().map(|e| e.to_string()).collect(); - anyhow::anyhow!("invalid filter: {}", msgs.join("; ")) - })?; - - let limit = params.limit.unwrap_or(100); - let mut results: Vec = Vec::new(); - - for artifact in proj.store.iter() { - if !rivet_core::sexpr_eval::matches_filter_with_store( - &expr, - artifact, - &proj.graph, - &proj.store, - ) { - continue; - } - let links_json: Vec = artifact - .links - .iter() - .map(|l| json!({"type": l.link_type, "target": l.target})) - .collect(); - results.push(json!({ - "id": artifact.id, - "type": artifact.artifact_type, - "title": artifact.title, - "status": artifact.status.as_deref().unwrap_or("-"), - "tags": artifact.tags, - "links": links_json, - "description": artifact.description.as_deref().unwrap_or(""), - })); - if results.len() >= limit { - break; - } - } + // Shared path with `rivet query` and the `{{query:...}}` embed so all + // three surfaces return the same artifact set for the same filter. + let result = rivet_core::query::execute_sexpr( + &proj.store, + &proj.graph, + ¶ms.filter, + Some(params.limit.unwrap_or(100)), + ) + .map_err(|e| anyhow::anyhow!("{e}"))?; + + let artifacts: Vec = result + .matches + .iter() + .map(|a| { + let links_json: Vec = a + .links + .iter() + .map(|l| json!({"type": l.link_type, "target": l.target})) + .collect(); + json!({ + "id": a.id, + "type": a.artifact_type, + "title": a.title, + "status": a.status.as_deref().unwrap_or("-"), + "tags": a.tags, + "links": links_json, + "description": a.description.as_deref().unwrap_or(""), + }) + }) + .collect(); Ok(json!({ "filter": params.filter, - "count": results.len(), - "artifacts": results, + "count": artifacts.len(), + "total": result.total, + "truncated": result.truncated, + "artifacts": artifacts, })) } diff --git a/rivet-cli/src/render/help.rs b/rivet-cli/src/render/help.rs index e9862e2..5420599 100644 --- a/rivet-cli/src/render/help.rs +++ b/rivet-cli/src/render/help.rs @@ -207,9 +207,58 @@ pub(crate) fn render_help(ctx: &RenderContext) -> String { html.push_str("rivet schema list List artifact types\n"); html.push_str("rivet schema show TYPE Show type details\n"); html.push_str("rivet docs List documentation topics\n"); + html.push_str("rivet docs embeds List {{...}} embed tokens\n"); html.push_str("rivet serve [-P PORT] Start dashboard\n"); html.push_str(""); + // Registered embeds — sourced from rivet_core::embed::EMBED_REGISTRY so + // the dashboard listing matches `rivet docs embeds` exactly. + html.push_str(&render_embed_registry()); + + html +} + +/// Render the embed registry table for the Help view. +/// +/// Mirrors the output of `rivet docs embeds` so users can discover +/// `{{stats}}`, `{{query:(...)}}`, `{{artifact:ID}}`, etc. without having +/// to read the source or grep the docs. +fn render_embed_registry() -> String { + use rivet_core::embed::registry; + let specs = registry(); + + let mut html = String::with_capacity(4096); + html.push_str( + r#"
+

Document Embeds

+

+ Use {{name[:args]}} inside an artifact description or document body. + Run rivet docs embeds for the same list from the CLI, or + rivet docs embed-syntax for the full reference. +

+ + + +"#, + ); + for s in specs { + html.push_str(&format!( + "\ + \ + \ + \n", + name = html_escape(s.name), + marker = if s.legacy { + r#" (inline)"# + } else { + "" + }, + args = html_escape(s.args), + summary = html_escape(s.summary), + example = html_escape(s.example), + )); + } + html.push_str("
NameArgsSummaryExample
{name}{marker}{args}{summary}{example}
"); html } diff --git a/rivet-cli/tests/cli_commands.rs b/rivet-cli/tests/cli_commands.rs index 247ac31..f02be47 100644 --- a/rivet-cli/tests/cli_commands.rs +++ b/rivet-cli/tests/cli_commands.rs @@ -1895,3 +1895,130 @@ fn validate_fail_on_invalid_value_rejected() { "error must mention the bad value, got: {stderr}" ); } + +// ── rivet query (REQ-007) ─────────────────────────────────────────────── + +/// `rivet query --sexpr ... --format ids` prints one ID per line. +#[test] +fn query_ids_format_matches_list_filter() { + let bin = rivet_bin(); + let root = project_root(); + + // `rivet list --type requirement` — one line per matching artifact (id + title). + let list_out = Command::new(&bin) + .args(["--project", &root.display().to_string(), "list", "--type", "requirement"]) + .output() + .expect("run rivet list"); + assert!(list_out.status.success(), "rivet list must succeed"); + let list_stdout = String::from_utf8_lossy(&list_out.stdout); + + // `rivet query --sexpr '(= type "requirement")' --format ids` → only IDs. + let query_out = Command::new(&bin) + .args([ + "--project", + &root.display().to_string(), + "query", + "--sexpr", + r#"(= type "requirement")"#, + "--limit", + "1000", + "--format", + "ids", + ]) + .output() + .expect("run rivet query"); + assert!( + query_out.status.success(), + "rivet query must succeed; stderr: {}", + String::from_utf8_lossy(&query_out.stderr) + ); + let query_stdout = String::from_utf8_lossy(&query_out.stdout); + let query_ids: Vec<&str> = query_stdout.lines().filter(|l| !l.is_empty()).collect(); + + assert!( + !query_ids.is_empty(), + "rivet query must return some requirements; got:\n{query_stdout}" + ); + + // Every ID that `rivet query` reports must also appear somewhere in + // `rivet list`'s output — confirms the two surfaces agree. + for id in &query_ids { + assert!( + list_stdout.contains(id), + "id {id} from `rivet query` not found in `rivet list --type requirement` output", + ); + } +} + +/// `rivet query --format json` produces MCP-shape output: filter, count, +/// total, truncated, artifacts[]. +#[test] +fn query_json_format_envelope() { + let bin = rivet_bin(); + let root = project_root(); + + let out = Command::new(&bin) + .args([ + "--project", + &root.display().to_string(), + "query", + "--sexpr", + r#"(= type "requirement")"#, + "--limit", + "5", + "--format", + "json", + ]) + .output() + .expect("run rivet query"); + + assert!( + out.status.success(), + "rivet query --format json must succeed; stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + let val: serde_json::Value = + serde_json::from_str(&stdout).expect("output must be valid JSON"); + + assert_eq!( + val["filter"].as_str(), + Some(r#"(= type "requirement")"#), + "filter field must echo input", + ); + assert!(val["count"].is_number(), "count must be a number"); + assert!(val["total"].is_number(), "total must be a number"); + assert!(val["truncated"].is_boolean(), "truncated must be a bool"); + let arts = val["artifacts"].as_array().expect("artifacts must be array"); + assert!(arts.len() <= 5, "respects --limit"); + for a in arts { + assert!(a["id"].is_string()); + assert!(a["type"].is_string()); + assert!(a["title"].is_string()); + } +} + +/// Invalid filter → non-zero exit with a helpful error. +#[test] +fn query_invalid_filter_reports_parse_error() { + let bin = rivet_bin(); + let root = project_root(); + + let out = Command::new(&bin) + .args([ + "--project", + &root.display().to_string(), + "query", + "--sexpr", + "(and (= type", // unbalanced + ]) + .output() + .expect("run rivet query"); + + assert!(!out.status.success(), "unbalanced filter must fail"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("invalid filter") || stderr.contains("filter"), + "stderr should mention the filter error; got: {stderr}" + ); +} diff --git a/rivet-cli/tests/embeds_help.rs b/rivet-cli/tests/embeds_help.rs new file mode 100644 index 0000000..a4fc46c --- /dev/null +++ b/rivet-cli/tests/embeds_help.rs @@ -0,0 +1,99 @@ +//! Integration tests for `rivet docs embeds` — the computed embed listing. +//! +//! The listing is sourced from `rivet_core::embed::EMBED_REGISTRY` so these +//! tests also serve as regressions for the registry itself. + +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") +} + +/// `rivet docs embeds` lists every registered computed embed. +#[test] +fn docs_embeds_lists_known_tokens() { + let output = Command::new(rivet_bin()) + .args(["docs", "embeds"]) + .output() + .expect("failed to execute rivet docs embeds"); + + assert!( + output.status.success(), + "rivet docs embeds must exit 0; stderr: {}", + String::from_utf8_lossy(&output.stderr), + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + + // All dispatched embed names must appear — if resolve_embed grows a + // new handler the author must also extend EMBED_REGISTRY. + for name in [ + "stats", + "coverage", + "diagnostics", + "matrix", + "query", + "group", + "artifact", + "links", + "table", + ] { + assert!( + stdout.contains(name), + "embed '{name}' missing from `rivet docs embeds` output:\n{stdout}" + ); + } + + // The output must be self-describing, not just a name dump. + assert!(stdout.contains("NAME"), "expected NAME header, got:\n{stdout}"); + assert!(stdout.contains("ARGS"), "expected ARGS header, got:\n{stdout}"); + // Legacy markers help users understand that artifact/links/table live + // in the inline resolver rather than resolve_embed. + assert!( + stdout.contains("(inline)"), + "legacy embeds should be marked; got:\n{stdout}" + ); + // Usage footer points users at concrete next steps. + assert!( + stdout.contains("rivet embed"), + "expected `rivet embed` usage hint, got:\n{stdout}" + ); +} + +/// `rivet docs embeds --format json` produces machine-readable output +/// matching the same registry. +#[test] +fn docs_embeds_json() { + let output = Command::new(rivet_bin()) + .args(["docs", "embeds", "--format", "json"]) + .output() + .expect("failed to execute rivet docs embeds --format json"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + + let val: serde_json::Value = + serde_json::from_str(&stdout).expect("output must be valid JSON"); + assert_eq!(val["command"], "docs-embeds"); + let embeds = val["embeds"].as_array().expect("embeds must be array"); + let names: Vec<&str> = embeds + .iter() + .filter_map(|v| v["name"].as_str()) + .collect(); + for required in ["stats", "coverage", "query", "group", "artifact"] { + assert!(names.contains(&required), "missing {required} in {names:?}"); + } + // Every entry has the four advertised fields. + for e in embeds { + assert!(e["name"].is_string()); + assert!(e["args"].is_string()); + assert!(e["summary"].is_string()); + assert!(e["example"].is_string()); + assert!(e["legacy"].is_boolean()); + } +} diff --git a/rivet-core/src/embed.rs b/rivet-core/src/embed.rs index ecf6ac3..bf4af77 100644 --- a/rivet-core/src/embed.rs +++ b/rivet-core/src/embed.rs @@ -103,12 +103,122 @@ impl<'a> EmbedContext<'a> { } } +// ── Embed registry ────────────────────────────────────────────────────── + +/// A single entry in the embed registry. +/// +/// Every `{{name[:args...]}}` token that `resolve_embed` or the document +/// inline resolver knows about has a matching `EmbedSpec`. This is the +/// single source of truth for `rivet docs embeds`, the dashboard Help view, +/// and any future UX that needs to enumerate embeds. +#[derive(Debug, Clone, Copy)] +pub struct EmbedSpec { + /// Embed name as it appears after `{{`. + pub name: &'static str, + /// Compact signature, e.g. `[section]` or `(sexpr) [limit=N]`. + pub args: &'static str, + /// One-line description for the listing. + pub summary: &'static str, + /// Runnable example that users can paste into a document. + pub example: &'static str, + /// True if handled by the inline resolver in `document.rs` rather than + /// by `resolve_embed`. Legacy embeds still appear in listings. + pub legacy: bool, +} + +/// The canonical list of registered embeds. +/// +/// Order is the order shown to users; group newest (or least-known) embeds +/// near the top of their family so they are discoverable. +pub const EMBED_REGISTRY: &[EmbedSpec] = &[ + EmbedSpec { + name: "stats", + args: "[section|type:NAME]", + summary: "Project statistics (types, status, validation) or count for a single type", + example: "{{stats}} / {{stats:types}} / {{stats:type:requirement}}", + legacy: false, + }, + EmbedSpec { + name: "coverage", + args: "[rule]", + summary: "Traceability coverage bars; with a rule name, lists uncovered IDs", + example: "{{coverage}} / {{coverage:req-implements-feat}}", + legacy: false, + }, + EmbedSpec { + name: "diagnostics", + args: "[severity]", + summary: "Validation findings (all, or filtered by error|warning|info)", + example: "{{diagnostics}} / {{diagnostics:error}}", + legacy: false, + }, + EmbedSpec { + name: "matrix", + args: "[FROM:TO]", + summary: "Traceability matrix — one per schema rule, or a specific type pair", + example: "{{matrix}} / {{matrix:requirement:feature}}", + legacy: false, + }, + EmbedSpec { + name: "query", + args: "(sexpr) [limit=N]", + summary: "Results of an s-expression filter as a compact table (id/type/title/status)", + example: "{{query:(and (= type \"requirement\") (has-tag \"stpa\"))}}", + legacy: false, + }, + EmbedSpec { + name: "group", + args: "FIELD", + summary: "Count-by-value table grouping artifacts by the given field", + example: "{{group:status}} / {{group:asil}}", + legacy: false, + }, + // Legacy embeds — resolved inline in document.rs, but still listed here + // so users can discover them via `rivet docs embeds`. + EmbedSpec { + name: "artifact", + args: "ID[:modifier[:depth]]", + summary: "Inline card for a single artifact (default|full|links|upstream|downstream|chain)", + example: "{{artifact:REQ-001}} / {{artifact:REQ-001:full}}", + legacy: true, + }, + EmbedSpec { + name: "links", + args: "ID", + summary: "Incoming + outgoing link table for an artifact", + example: "{{links:REQ-001}}", + legacy: true, + }, + EmbedSpec { + name: "table", + args: "TYPE:FIELDS", + summary: "Filtered artifact table for a single type with comma-separated columns", + example: "{{table:requirement:id,title,status}}", + legacy: true, + }, +]; + +/// Return the full embed registry. +/// +/// Convenience accessor — callers that want the raw slice can also use +/// `EMBED_REGISTRY` directly. +pub fn registry() -> &'static [EmbedSpec] { + EMBED_REGISTRY +} + // ── Parsing ───────────────────────────────────────────────────────────── impl EmbedRequest { /// Parse a raw embed string (the content between `{{` and `}}`). /// /// Syntax: `name[:arg1[:arg2[...]]] [key=val ...]` + /// + /// Special case: when `name == "query"` the first argument is an + /// s-expression which contains `(`, `)`, `"` and its own `:` — so + /// naive `split(':')` / `split_whitespace()` would corrupt it. The + /// parser therefore requires the query form `query:(...)` and + /// captures balanced parens as the single positional arg, leaving any + /// trailing `key=val` options after the closing `)` intact. pub fn parse(input: &str) -> Result { let input = input.trim(); if input.is_empty() { @@ -118,18 +228,77 @@ impl EmbedRequest { }); } - // Split on first space to separate "name:args..." from "key=val ..." - let (name_args_part, options_part) = match input.find(' ') { - Some(pos) => (&input[..pos], Some(&input[pos + 1..])), - None => (input, None), + // Peel off the embed name (everything up to the first ':' or space). + let name_end = input + .find(|c: char| c == ':' || c.is_whitespace()) + .unwrap_or(input.len()); + let name = input[..name_end].to_string(); + let rest = input[name_end..].trim_start_matches(':'); + + // ── Balanced-paren form for `query` ──────────────────────── + // `{{query:(..balanced..) key=val}}`. Any colons, spaces, and + // quotes inside the parens belong to the s-expression. + if name == "query" { + let rest_trim = rest.trim_start(); + if !rest_trim.starts_with('(') { + return Err(EmbedError { + kind: EmbedErrorKind::MalformedSyntax( + "query embed requires a parenthesised s-expression: {{query:(...)}}" + .into(), + ), + raw_text: input.to_string(), + }); + } + let (sexpr, tail) = match extract_balanced_parens(rest_trim) { + Some(pair) => pair, + None => { + return Err(EmbedError { + kind: EmbedErrorKind::MalformedSyntax( + "unbalanced parentheses in query embed".into(), + ), + raw_text: input.to_string(), + }); + } + }; + + let mut options = BTreeMap::new(); + for token in tail.split_whitespace() { + if let Some((key, val)) = token.split_once('=') { + options.insert(key.to_string(), val.to_string()); + } + } + return Ok(EmbedRequest { + name, + args: vec![sexpr.to_string()], + options, + }); + } + + // ── Classic form: name:arg1:arg2 key=val ... ─────────────── + // (Re-assemble input so the whitespace/option parser sees the + // original shape.) + let tail_full = if rest.is_empty() { input } else { rest }; + // If `rest` is a slice of `input`, we need to re-anchor the "name" + // prefix logic on the tail (arguments only). + let args_and_options = if name_end == input.len() { + "" + } else { + input[name_end..].trim_start_matches(':') + }; + let (args_part, options_part) = match args_and_options.find(' ') { + Some(pos) => ( + &args_and_options[..pos], + Some(&args_and_options[pos + 1..]), + ), + None => (args_and_options, None), }; - // Split name:arg1:arg2:... - let mut parts = name_args_part.split(':'); - let name = parts.next().unwrap().to_string(); - let args: Vec = parts.map(|s| s.trim().to_string()).collect(); + let args: Vec = if args_part.is_empty() { + Vec::new() + } else { + args_part.split(':').map(|s| s.trim().to_string()).collect() + }; - // Parse key=val options let mut options = BTreeMap::new(); if let Some(opts_str) = options_part { for token in opts_str.split_whitespace() { @@ -139,6 +308,9 @@ impl EmbedRequest { } } + // Silence unused-variable lint for the legacy shadow. + let _ = tail_full; + Ok(EmbedRequest { name, args, @@ -165,6 +337,8 @@ pub fn resolve_embed(request: &EmbedRequest, ctx: &EmbedContext<'_>) -> Result Ok(render_coverage(request, ctx)), "diagnostics" => Ok(render_diagnostics(request, ctx)), "matrix" => Ok(render_matrix(request, ctx)), + "query" => render_query(request, ctx), + "group" => render_group(request, ctx), // Legacy embeds (artifact, links, table) are still handled by // resolve_inline in document.rs — they should never reach here. "artifact" | "links" | "table" => Err(EmbedError { @@ -180,11 +354,64 @@ pub fn resolve_embed(request: &EmbedRequest, ctx: &EmbedContext<'_>) -> Result Option<(&str, &str)> { + let bytes = s.as_bytes(); + if bytes.first() != Some(&b'(') { + return None; + } + let mut depth = 0i32; + let mut in_string = false; + let mut escape = false; + for (i, b) in bytes.iter().enumerate() { + let c = *b; + if in_string { + if escape { + escape = false; + } else if c == b'\\' { + escape = true; + } else if c == b'"' { + in_string = false; + } + continue; + } + match c { + b'"' => in_string = true, + b'(' => depth += 1, + b')' => { + depth -= 1; + if depth == 0 { + // include the closing paren in the first slice + let (head, tail) = s.split_at(i + 1); + return Some((head, tail)); + } + } + _ => {} + } + } + None +} + // ── Stats renderer ────────────────────────────────────────────────────── -/// Render `{{stats}}` / `{{stats:types}}` / `{{stats:status}}` / `{{stats:validation}}`. +/// Render one of: +/// - `{{stats}}` — full statistics panel (types + status + validation) +/// - `{{stats:types}}` — just the type-count table +/// - `{{stats:status}}` — just the status-count table +/// - `{{stats:validation}}` — just the per-severity table +/// - `{{stats:type:NAME}}` — single count for the named artifact type fn render_stats(request: &EmbedRequest, ctx: &EmbedContext<'_>) -> String { let section = request.args.first().map(|s| s.as_str()); + let target_type = request.args.get(1).map(|s| s.as_str()); + + // Granular form: {{stats:type:requirement}} → single-cell count. + if section == Some("type") { + return render_stats_single_type(target_type.unwrap_or(""), ctx); + } + let mut html = String::from("
\n"); let show_types = section.is_none() || section == Some("types"); @@ -205,6 +432,32 @@ fn render_stats(request: &EmbedRequest, ctx: &EmbedContext<'_>) -> String { html } +/// Render `{{stats:type:NAME}}` — just the count for a single artifact type. +/// +/// Rendered as a compact single-row table so it still looks like the rest of +/// the stats family. Unknown types render a zero-count row rather than an +/// error: this is the "embed never disappears silently" rule (SC-EMBED-3). +fn render_stats_single_type(type_name: &str, ctx: &EmbedContext<'_>) -> String { + let name = type_name.trim(); + if name.is_empty() { + return "
stats:type requires a type name, e.g. {{stats:type:requirement}}
\n".to_string(); + } + let count = ctx + .store + .iter() + .filter(|a| a.artifact_type == name) + .count(); + + format!( + "
\n\ + \n\ + \n\ +
TypeCount
{typ}{count}
\n\ +
\n", + typ = document::html_escape(name), + ) +} + fn render_stats_types(ctx: &EmbedContext<'_>) -> String { let mut by_type = BTreeMap::new(); for type_name in ctx.schema.artifact_types.keys() { @@ -624,6 +877,211 @@ fn auto_detect_link(ctx: &EmbedContext<'_>, from: &str, _to: &str) -> String { String::new() } +// ── Query renderer ────────────────────────────────────────────────────── + +/// Default maximum rows a `{{query:...}}` embed will render. +pub const QUERY_EMBED_DEFAULT_LIMIT: usize = 50; +/// Hard upper bound on `limit=N` for `{{query:...}}`; keeps render time bounded. +pub const QUERY_EMBED_MAX_LIMIT: usize = 500; + +/// Render `{{query:(s-expr) [limit=N]}}`. +/// +/// Reuses `sexpr_eval::parse_filter` and `matches_filter_with_store` — the +/// same path used by `rivet list --filter`, MCP's `rivet_query`, and the +/// `rivet query` CLI — so output IDs agree across all three surfaces. +/// +/// Read-only by construction (the evaluator has no I/O), and truncation is +/// reported as a visible footer rather than silently dropping rows. +fn render_query(request: &EmbedRequest, ctx: &EmbedContext<'_>) -> Result { + let Some(sexpr) = request.args.first() else { + return Err(EmbedError { + kind: EmbedErrorKind::MalformedSyntax( + "query embed requires an s-expression: {{query:(...)}}".into(), + ), + raw_text: format!("{request:?}"), + }); + }; + + let expr = crate::sexpr_eval::parse_filter(sexpr).map_err(|errs| { + let msgs: Vec = errs.iter().map(|e| e.to_string()).collect(); + EmbedError { + kind: EmbedErrorKind::MalformedSyntax(format!("invalid filter: {}", msgs.join("; "))), + raw_text: sexpr.clone(), + } + })?; + + // Resolve limit: options["limit"] if valid, else default. Clamped to + // the hard max so a stray `limit=99999` cannot pin the renderer. + let limit = request + .options + .get("limit") + .and_then(|s| s.parse::().ok()) + .unwrap_or(QUERY_EMBED_DEFAULT_LIMIT) + .min(QUERY_EMBED_MAX_LIMIT); + + let mut matches: Vec<&crate::model::Artifact> = Vec::new(); + let mut total = 0usize; + for artifact in ctx.store.iter() { + if crate::sexpr_eval::matches_filter_with_store(&expr, artifact, ctx.graph, ctx.store) { + total += 1; + if matches.len() < limit { + matches.push(artifact); + } + } + } + + let mut html = String::from("
\n"); + if total == 0 { + html.push_str("

No artifacts match this query.

\n"); + html.push_str("
\n"); + return Ok(html); + } + + html.push_str( + "\ + \ + \n", + ); + for a in &matches { + let _ = writeln!( + html, + "", + id = document::html_escape(&a.id), + typ = document::html_escape(&a.artifact_type), + title = document::html_escape(&a.title), + status = document::html_escape(a.status.as_deref().unwrap_or("-")), + ); + } + html.push_str("
IDTypeTitleStatus
{id}{typ}{title}{status}
\n"); + + if total > matches.len() { + let _ = writeln!( + html, + "

Showing {shown} of {total} — narrow the filter or raise limit= to see more.

", + shown = matches.len(), + ); + } else { + let _ = writeln!( + html, + "

{total} result{s}.

", + s = if total == 1 { "" } else { "s" }, + ); + } + html.push_str("
\n"); + Ok(html) +} + +// ── Group renderer ────────────────────────────────────────────────────── + +/// Render `{{group:FIELD}}` — count-by-value table grouping existing +/// artifacts by the given field. +/// +/// Examples: +/// - `{{group:status}}` — counts of draft / approved / shipped / unset +/// - `{{group:type}}` — like `{{stats:types}}` without schema pre-population +/// - `{{group:asil}}` — per-ASIL counts from a custom field +/// +/// The "meaning" of this embed is not fully pinned down by prior docs — +/// this implementation picks the most useful reading (count-by-value) and +/// documents it alongside the output. Unset / missing values are bucketed +/// as "unset" so the totals line up with the project artifact count. +fn render_group(request: &EmbedRequest, ctx: &EmbedContext<'_>) -> Result { + let Some(field) = request.args.first() else { + return Err(EmbedError { + kind: EmbedErrorKind::MalformedSyntax( + "group embed requires a field name: {{group:status}}".into(), + ), + raw_text: format!("{request:?}"), + }); + }; + let field = field.trim(); + if field.is_empty() { + return Err(EmbedError { + kind: EmbedErrorKind::MalformedSyntax("group field cannot be empty".into()), + raw_text: format!("{request:?}"), + }); + } + + let mut counts: BTreeMap = BTreeMap::new(); + for a in ctx.store.iter() { + let raw = read_artifact_field(a, field); + // Treat empty/missing as "unset" so the totals always add up. + let bucket = if raw.is_empty() { + "unset".to_string() + } else { + raw + }; + *counts.entry(bucket).or_default() += 1; + } + + if counts.is_empty() { + return Ok(format!( + "

No artifacts to group by {}.

\n", + document::html_escape(field) + )); + } + + let total: usize = counts.values().sum(); + let mut html = String::from("
\n"); + let _ = writeln!( + html, + "", + fld = document::html_escape(field), + ); + for (value, count) in &counts { + let _ = writeln!( + html, + "", + v = document::html_escape(value), + c = count, + ); + } + let _ = writeln!( + html, + "" + ); + html.push_str("
{fld}Count
{v}{c}
Total{total}
\n
\n"); + Ok(html) +} + +/// Read a single string value for an artifact field by name. +/// +/// Handles the well-known top-level fields (id, type, title, status, +/// description) as well as YAML extension fields stored in `fields`. +/// List-valued fields (e.g. `tags`) render as a comma-joined string so +/// `{{group:tags}}` produces stable buckets — individual-tag grouping is +/// a future enhancement. +fn read_artifact_field(a: &crate::model::Artifact, name: &str) -> String { + match name { + "id" => a.id.clone(), + "type" => a.artifact_type.clone(), + "title" => a.title.clone(), + "description" => a.description.clone().unwrap_or_default(), + "status" => a.status.clone().unwrap_or_default(), + "tags" => a.tags.join(","), + other => a + .fields + .get(other) + .map(yaml_value_to_plain_string) + .unwrap_or_default(), + } +} + +fn yaml_value_to_plain_string(v: &serde_yaml::Value) -> String { + match v { + serde_yaml::Value::String(s) => s.clone(), + serde_yaml::Value::Number(n) => n.to_string(), + serde_yaml::Value::Bool(b) => b.to_string(), + serde_yaml::Value::Null => String::new(), + serde_yaml::Value::Sequence(seq) => seq + .iter() + .map(yaml_value_to_plain_string) + .collect::>() + .join(","), + serde_yaml::Value::Mapping(_) | serde_yaml::Value::Tagged(_) => format!("{v:?}"), + } +} + // ── Provenance ────────────────────────────────────────────────────────── /// Render a provenance footer for export (SC-EMBED-4). @@ -923,4 +1381,442 @@ mod tests { assert!(!EmbedRequest::parse("diagnostics").unwrap().is_legacy()); assert!(!EmbedRequest::parse("matrix").unwrap().is_legacy()); } + + // ── Balanced-paren / query parsing ────────────────────────────── + + #[test] + fn extract_balanced_parens_simple() { + let (head, tail) = extract_balanced_parens("(= type \"requirement\") limit=5").unwrap(); + assert_eq!(head, "(= type \"requirement\")"); + assert_eq!(tail, " limit=5"); + } + + #[test] + fn extract_balanced_parens_nested() { + let (head, tail) = + extract_balanced_parens("(and (= type \"requirement\") (has-tag \"stpa\"))").unwrap(); + assert_eq!(head, "(and (= type \"requirement\") (has-tag \"stpa\"))"); + assert_eq!(tail, ""); + } + + #[test] + fn extract_balanced_parens_respects_string_literal() { + // a `)` inside a string must not close the group + let (head, _tail) = extract_balanced_parens(r#"(= title "has ) paren")"#).unwrap(); + assert_eq!(head, r#"(= title "has ) paren")"#); + } + + #[test] + fn extract_balanced_parens_unbalanced_returns_none() { + assert!(extract_balanced_parens("(and (=").is_none()); + } + + #[test] + fn parse_query_captures_whole_sexpr() { + let req = EmbedRequest::parse("query:(= type \"requirement\")").unwrap(); + assert_eq!(req.name, "query"); + assert_eq!(req.args, vec!["(= type \"requirement\")"]); + } + + #[test] + fn parse_query_with_nested_and_options() { + let req = EmbedRequest::parse( + "query:(and (= type \"requirement\") (has-tag \"stpa\")) limit=25", + ) + .unwrap(); + assert_eq!(req.name, "query"); + assert_eq!( + req.args, + vec!["(and (= type \"requirement\") (has-tag \"stpa\"))"] + ); + assert_eq!(req.options.get("limit"), Some(&"25".to_string())); + } + + #[test] + fn parse_query_without_parens_errors() { + let err = EmbedRequest::parse("query:type=requirement").unwrap_err(); + assert!(matches!(err.kind, EmbedErrorKind::MalformedSyntax(_))); + } + + #[test] + fn parse_query_with_unbalanced_parens_errors() { + let err = EmbedRequest::parse("query:(and (= type \"req\"").unwrap_err(); + assert!(matches!(err.kind, EmbedErrorKind::MalformedSyntax(_))); + } + + // Regression: parser changes for `query` must not break existing embeds. + + #[test] + fn parse_stats_still_splits_on_colon() { + let req = EmbedRequest::parse("stats:types").unwrap(); + assert_eq!(req.name, "stats"); + assert_eq!(req.args, vec!["types"]); + } + + #[test] + fn parse_table_still_takes_two_args() { + let req = EmbedRequest::parse("table:requirement:id,title,status").unwrap(); + assert_eq!(req.name, "table"); + assert_eq!(req.args, vec!["requirement", "id,title,status"]); + } + + // ── Query & group renderers ───────────────────────────────────── + + use crate::links::LinkGraph; + use crate::model::Artifact; + use crate::schema::Schema; + use crate::store::Store; + use crate::validate::Diagnostic; + use std::collections::BTreeMap; + + fn make_store(artifacts: Vec) -> Store { + let mut s = Store::new(); + for a in artifacts { + s.upsert(a); + } + s + } + + fn plain(id: &str, typ: &str, status: Option<&str>, tags: &[&str]) -> Artifact { + Artifact { + id: id.into(), + artifact_type: typ.into(), + title: format!("Title of {id}"), + description: None, + status: status.map(|s| s.into()), + tags: tags.iter().map(|t| t.to_string()).collect(), + links: vec![], + fields: BTreeMap::new(), + provenance: None, + source_file: None, + } + } + + fn run_embed( + query: &str, + store: &Store, + schema: &Schema, + graph: &LinkGraph, + ) -> Result { + let req = EmbedRequest::parse(query)?; + let diags: Vec = Vec::new(); + let ctx = EmbedContext { + store, + schema, + graph, + diagnostics: &diags, + baseline: None, + }; + resolve_embed(&req, &ctx) + } + + // The `{{query:...}}` embed must return the same IDs as + // `sexpr_eval::matches_filter_with_store` — and therefore the same set + // that `rivet list --filter` and MCP's `rivet_query` would return. + #[test] + fn query_embed_matches_sexpr_filter() { + let store = make_store(vec![ + plain("REQ-1", "requirement", Some("approved"), &["stpa"]), + plain("REQ-2", "requirement", Some("draft"), &[]), + plain("FEAT-1", "feature", Some("approved"), &["stpa"]), + ]); + let schema = Schema::merge(&[]); + let graph = LinkGraph::build(&store, &schema); + + let html = run_embed( + r#"query:(= type "requirement")"#, + &store, + &schema, + &graph, + ) + .unwrap(); + assert!(html.contains("REQ-1"), "got: {html}"); + assert!(html.contains("REQ-2"), "got: {html}"); + assert!(!html.contains("FEAT-1"), "got: {html}"); + + // Cross-check via the same evaluator directly. Store iteration + // order is not guaranteed, so compare as a sorted set. + let expr = crate::sexpr_eval::parse_filter(r#"(= type "requirement")"#).unwrap(); + let mut direct_ids: Vec = store + .iter() + .filter(|a| crate::sexpr_eval::matches_filter_with_store(&expr, a, &graph, &store)) + .map(|a| a.id.clone()) + .collect(); + direct_ids.sort(); + assert_eq!(direct_ids, vec!["REQ-1".to_string(), "REQ-2".to_string()]); + } + + #[test] + fn query_embed_no_matches_shows_empty_message() { + let store = make_store(vec![plain("REQ-1", "requirement", None, &[])]); + let schema = Schema::merge(&[]); + let graph = LinkGraph::build(&store, &schema); + let html = run_embed(r#"query:(= type "feature")"#, &store, &schema, &graph).unwrap(); + assert!(html.contains("No artifacts match"), "got: {html}"); + assert!(html.contains("embed-query")); + } + + #[test] + fn query_embed_limit_caps_rows_and_shows_truncation_note() { + let store = make_store( + (0..20) + .map(|i| plain(&format!("REQ-{i:03}"), "requirement", None, &[])) + .collect(), + ); + let schema = Schema::merge(&[]); + let graph = LinkGraph::build(&store, &schema); + let html = run_embed( + r#"query:(= type "requirement") limit=3"#, + &store, + &schema, + &graph, + ) + .unwrap(); + // Only 3 data rows render, and a footer flags the truncation. + // (Store iteration order is not guaranteed — we assert row count + // and the summary, not specific IDs.) + let row_count = html.matches("").count(); + assert_eq!(row_count, 4, "expected 1 header + 3 data rows, got: {html}"); + assert!(html.contains("Showing 3 of 20"), "got: {html}"); + } + + #[test] + fn query_embed_limit_clamped_to_hard_max() { + // Just verify that an over-limit renders at all without panicking. + let store = make_store(vec![plain("REQ-1", "requirement", None, &[])]); + let schema = Schema::merge(&[]); + let graph = LinkGraph::build(&store, &schema); + let html = run_embed( + &format!( + "query:(= type \"requirement\") limit={}", + QUERY_EMBED_MAX_LIMIT + 1_000 + ), + &store, + &schema, + &graph, + ) + .unwrap(); + assert!(html.contains("REQ-1")); + } + + #[test] + fn query_embed_malformed_filter_renders_error() { + let store = Store::new(); + let schema = Schema::merge(&[]); + let graph = LinkGraph::build(&store, &schema); + // `(and` unclosed — passes the paren-balancer only when wrapped. + let req = EmbedRequest::parse("query:(unknown-form)").unwrap(); + let diags: Vec = Vec::new(); + let ctx = EmbedContext { + store: &store, + schema: &schema, + graph: &graph, + diagnostics: &diags, + baseline: None, + }; + let err = resolve_embed(&req, &ctx).unwrap_err(); + assert!(matches!(err.kind, EmbedErrorKind::MalformedSyntax(_))); + } + + // ── stats:type:NAME granular form ─────────────────────────────── + + #[test] + fn stats_type_single_name_counts_correctly() { + let store = make_store(vec![ + plain("A", "requirement", None, &[]), + plain("B", "requirement", None, &[]), + plain("C", "feature", None, &[]), + ]); + let schema = Schema::merge(&[]); + let graph = LinkGraph::build(&store, &schema); + let html = run_embed("stats:type:requirement", &store, &schema, &graph).unwrap(); + assert!(html.contains("embed-stats-single"), "got: {html}"); + // The single-type row must show count = 2 for requirement. + assert!(html.contains("requirement"), "got: {html}"); + assert!(html.contains("2"), "got: {html}"); + // Must NOT contain the full stats table sections. + assert!(!html.contains("embed-stats-validation"), "got: {html}"); + assert!(!html.contains("embed-stats-status"), "got: {html}"); + } + + #[test] + fn stats_type_unknown_type_renders_zero_not_error() { + let store = make_store(vec![plain("A", "requirement", None, &[])]); + let schema = Schema::merge(&[]); + let graph = LinkGraph::build(&store, &schema); + let html = run_embed("stats:type:nonexistent", &store, &schema, &graph).unwrap(); + // Still renders a table cell (SC-EMBED-3: no silent disappearance). + assert!(html.contains("nonexistent"), "got: {html}"); + assert!(html.contains("0"), "got: {html}"); + } + + #[test] + fn stats_type_empty_name_renders_embed_error() { + let store = Store::new(); + let schema = Schema::merge(&[]); + let graph = LinkGraph::build(&store, &schema); + // `{{stats:type}}` with no third arg — flag as user error, visibly. + let html = run_embed("stats:type", &store, &schema, &graph).unwrap(); + assert!(html.contains("embed-error"), "got: {html}"); + } + + #[test] + fn stats_type_does_not_break_existing_stats_types() { + // Regression: the previous {{stats:types}} form must still render + // the full table. + let store = make_store(vec![ + plain("A", "requirement", None, &[]), + plain("B", "feature", None, &[]), + ]); + let schema = Schema::merge(&[]); + let graph = LinkGraph::build(&store, &schema); + let html = run_embed("stats:types", &store, &schema, &graph).unwrap(); + assert!(html.contains("4"), "got: {html}"); + } + + #[test] + fn group_embed_counts_by_type() { + let store = make_store(vec![ + plain("A", "requirement", None, &[]), + plain("B", "feature", None, &[]), + plain("C", "feature", None, &[]), + ]); + let schema = Schema::merge(&[]); + let graph = LinkGraph::build(&store, &schema); + let html = run_embed("group:type", &store, &schema, &graph).unwrap(); + // two features, one requirement — assert the cells directly. + assert!(html.contains("feature"), "got: {html}"); + assert!(html.contains("2"), "got: {html}"); + assert!(html.contains("requirement"), "got: {html}"); + assert!(html.contains("1"), "got: {html}"); + } + + #[test] + fn group_embed_by_custom_field() { + // ASIL is a common custom YAML field; group-by that. + let mut a = plain("A", "requirement", None, &[]); + a.fields.insert( + "asil".into(), + serde_yaml::Value::String("ASIL-B".into()), + ); + let mut b = plain("B", "requirement", None, &[]); + b.fields.insert( + "asil".into(), + serde_yaml::Value::String("ASIL-B".into()), + ); + let c = plain("C", "requirement", None, &[]); // no asil → unset + let store = make_store(vec![a, b, c]); + let schema = Schema::merge(&[]); + let graph = LinkGraph::build(&store, &schema); + let html = run_embed("group:asil", &store, &schema, &graph).unwrap(); + assert!(html.contains("ASIL-B"), "got: {html}"); + assert!(html.contains("2"), "got: {html}"); + assert!(html.contains("unset"), "got: {html}"); + } + + #[test] + fn group_embed_rejects_empty_field() { + let store = Store::new(); + let schema = Schema::merge(&[]); + let graph = LinkGraph::build(&store, &schema); + let req = EmbedRequest::parse("group:").unwrap(); + let diags: Vec = Vec::new(); + let ctx = EmbedContext { + store: &store, + schema: &schema, + graph: &graph, + diagnostics: &diags, + baseline: None, + }; + let err = resolve_embed(&req, &ctx).unwrap_err(); + assert!(matches!(err.kind, EmbedErrorKind::MalformedSyntax(_))); + } + + #[test] + fn group_embed_empty_store_renders_no_data() { + let store = Store::new(); + let schema = Schema::merge(&[]); + let graph = LinkGraph::build(&store, &schema); + let html = run_embed("group:status", &store, &schema, &graph).unwrap(); + assert!(html.contains("embed-group"), "got: {html}"); + assert!(html.contains("No artifacts"), "got: {html}"); + } + + // ── Registry invariants ───────────────────────────────────────── + + /// Every embed that resolve_embed dispatches must appear in + /// EMBED_REGISTRY — otherwise `rivet docs embeds` lies by omission. + #[test] + fn registry_covers_all_dispatched_embeds() { + let dispatched = [ + "stats", + "coverage", + "diagnostics", + "matrix", + "query", + "group", + // Legacy — still listed: + "artifact", + "links", + "table", + ]; + let registered: Vec<&str> = EMBED_REGISTRY.iter().map(|s| s.name).collect(); + for name in &dispatched { + assert!( + registered.contains(name), + "embed '{name}' is dispatched but not in EMBED_REGISTRY", + ); + } + } + + /// Each registry entry's example must itself be a parseable embed so + /// the listing output is copy-pasteable without further editing. + #[test] + fn registry_examples_parse() { + for spec in EMBED_REGISTRY { + // Strip the outer {{ }} and parse the first example. Many + // examples list multiple variants separated by " / "; + // testing the first is enough to catch regressions. + let first = spec.example.split(" / ").next().unwrap().trim(); + let inner = first + .trim_start_matches("{{") + .trim_end_matches("}}") + .trim(); + EmbedRequest::parse(inner) + .unwrap_or_else(|e| panic!("registry example for '{}' failed to parse: {e}", spec.name)); + } + } + + #[test] + fn registry_has_stable_entries() { + // Smoke test: hold the registry to at least these entries. Stops + // accidental deletions. + let names: Vec<&str> = EMBED_REGISTRY.iter().map(|s| s.name).collect(); + for required in ["stats", "coverage", "query", "group", "artifact"] { + assert!(names.contains(&required)); + } + } } diff --git a/rivet-core/src/markdown.rs b/rivet-core/src/markdown.rs index 2b2a4dd..62a78ee 100644 --- a/rivet-core/src/markdown.rs +++ b/rivet-core/src/markdown.rs @@ -49,27 +49,65 @@ fn sanitize_html(html: &str) -> String { /// Enables tables, strikethrough, and task lists on top of the CommonMark base. /// Used for artifact descriptions, field values, and document content. /// -/// Security: raw HTML events are filtered at the pulldown-cmark level, and a -/// regex-based sanitization pass strips dangerous tags (`