diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index ebd34f5..7152cf3 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -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, } @@ -902,6 +912,7 @@ 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), } } @@ -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"); @@ -5028,6 +5050,75 @@ fn cmd_batch(cli: &Cli, file: &std::path::Path) -> Result { Ok(true) } +fn cmd_embed(cli: &Cli, query: &str, format: &str) -> Result { + 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("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") +} + fn cmd_lsp(cli: &Cli) -> Result { use lsp_server::{Connection, Message, Response}; use lsp_types::*; diff --git a/rivet-cli/src/render/documents.rs b/rivet-cli/src/render/documents.rs index 895b0d5..7f0bae8 100644 --- a/rivet-cli/src/render/documents.rs +++ b/rivet-cli/src/render/documents.rs @@ -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(""); diff --git a/rivet-cli/src/render/source.rs b/rivet-cli/src/render/source.rs index 80346ae..31b2d16 100644 --- a/rivet-cli/src/render/source.rs +++ b/rivet-cli/src/render/source.rs @@ -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); diff --git a/rivet-cli/tests/cli_commands.rs b/rivet-cli/tests/cli_commands.rs index 047446e..05639e6 100644 --- a/rivet-cli/tests/cli_commands.rs +++ b/rivet-cli/tests/cli_commands.rs @@ -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}" + ); +} diff --git a/rivet-cli/tests/serve_integration.rs b/rivet-cli/tests/serve_integration.rs index 1ac7feb..3936de5 100644 --- a/rivet-cli/tests/serve_integration.rs +++ b/rivet-cli/tests/serve_integration.rs @@ -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(); +} diff --git a/rivet-core/src/document.rs b/rivet-core/src/document.rs index 96861d8..da2a3ff 100644 --- a/rivet-core/src/document.rs +++ b/rivet-core/src/document.rs @@ -332,6 +332,7 @@ pub fn render_to_html( artifact_exists: impl Fn(&str) -> bool, artifact_info: impl Fn(&str) -> Option, document_exists: impl Fn(&str) -> bool, + embed_resolver: impl Fn(&crate::embed::EmbedRequest) -> Result, ) -> String { let mut html = String::with_capacity(doc.body.len() * 2); let mut in_list = false; @@ -464,7 +465,7 @@ pub fn render_to_html( in_blockquote = false; } let text = &trimmed[level as usize + 1..]; - let text = resolve_inline(text, &artifact_exists, &artifact_info, &document_exists); + let text = resolve_inline(text, &artifact_exists, &artifact_info, &document_exists, &embed_resolver); html.push_str(&format!("{text}\n")); continue; } @@ -504,7 +505,7 @@ pub fn render_to_html( html.push_str(""); for cell in &cells { let text = - resolve_inline(cell, &artifact_exists, &artifact_info, &document_exists); + resolve_inline(cell, &artifact_exists, &artifact_info, &document_exists, &embed_resolver); html.push_str(&format!("")); } html.push_str("\n"); @@ -514,7 +515,7 @@ pub fn render_to_html( html.push_str(""); for cell in &cells { let text = - resolve_inline(cell, &artifact_exists, &artifact_info, &document_exists); + resolve_inline(cell, &artifact_exists, &artifact_info, &document_exists, &embed_resolver); html.push_str(&format!("")); } html.push_str("\n"); @@ -545,7 +546,7 @@ pub fn render_to_html( html.push_str("
"); in_blockquote = true; } - let text = resolve_inline(bq_text, &artifact_exists, &artifact_info, &document_exists); + let text = resolve_inline(bq_text, &artifact_exists, &artifact_info, &document_exists, &embed_resolver); html.push_str(&format!("

{text}

")); continue; } @@ -578,6 +579,7 @@ pub fn render_to_html( &artifact_exists, &artifact_info, &document_exists, + &embed_resolver, ); html.push_str(&format!("
  • {text}
  • \n")); continue; @@ -606,7 +608,7 @@ pub fn render_to_html( html.push_str("
      \n"); in_ordered_list = true; } - let text = resolve_inline(rest, &artifact_exists, &artifact_info, &document_exists); + let text = resolve_inline(rest, &artifact_exists, &artifact_info, &document_exists, &embed_resolver); html.push_str(&format!("
    1. {text}
    2. \n")); continue; } @@ -640,6 +642,7 @@ pub fn render_to_html( &artifact_exists, &artifact_info, &document_exists, + &embed_resolver, )); } @@ -685,6 +688,7 @@ fn resolve_inline( artifact_exists: &impl Fn(&str) -> bool, artifact_info: &impl Fn(&str) -> Option, document_exists: &impl Fn(&str) -> bool, + embed_resolver: &impl Fn(&crate::embed::EmbedRequest) -> Result, ) -> String { let mut result = String::with_capacity(text.len() * 2); let mut chars = text.char_indices().peekable(); @@ -816,6 +820,26 @@ fn resolve_inline( } continue; } + + // Computed embeds: dispatch to embed_resolver closure + if let Ok(req) = crate::embed::EmbedRequest::parse(inner) { + if !req.is_legacy() { + match embed_resolver(&req) { + Ok(html) => result.push_str(&html), + Err(err_html) => { + result.push_str(&format!( + "{}", + html_escape(&err_html), + )); + } + } + let skip_to = i + end + 2; + while chars.peek().is_some_and(|&(j, _)| j < skip_to) { + chars.next(); + } + continue; + } + } } } @@ -1369,6 +1393,10 @@ impl DocumentStore { mod tests { use super::*; + fn noop_embed(_req: &crate::embed::EmbedRequest) -> Result { + Ok(String::new()) + } + const SAMPLE_DOC: &str = r#"--- id: SRS-001 type: specification @@ -1474,6 +1502,7 @@ See frontmatter. |id| id == "REQ-001" || id == "REQ-002", |_| None, |_| false, + noop_embed, ); assert!(html.contains("artifact-ref")); assert!(html.contains("hx-get=\"/artifacts/REQ-001\"")); @@ -1484,7 +1513,7 @@ See frontmatter. #[test] fn render_html_headings() { let doc = parse_document(SAMPLE_DOC, None).unwrap(); - let html = render_to_html(&doc, |_| true, |_| None, |_| false); + let html = render_to_html(&doc, |_| true, |_| None, |_| false, noop_embed); assert!(html.contains("

      ")); assert!(html.contains("

      ")); assert!(html.contains("

      ")); @@ -1514,7 +1543,7 @@ See frontmatter. fn render_aadl_code_block_placeholder() { let content = "---\nid: DOC-001\ntitle: Architecture\n---\n\n## Overview\n\n```aadl\nroot: FlightControl::Controller.Basic\n```\n\nSome text after.\n"; let doc = parse_document(content, None).unwrap(); - let html = render_to_html(&doc, |_| true, |_| None, |_| false); + let html = render_to_html(&doc, |_| true, |_| None, |_| false, noop_embed); assert!(html.contains("aadl-diagram")); assert!(html.contains("data-root=\"FlightControl::Controller.Basic\"")); assert!(!html.contains("
      root: FlightControl"));
      @@ -1574,7 +1603,7 @@ See frontmatter.
               let content =
                   "---\nid: DOC-E\ntitle: Embed Test\n---\nSee {{artifact:REQ-001}} for details.\n";
               let doc = parse_document(content, None).unwrap();
      -        let html = render_to_html(&doc, |_| true, info_fn, |_| false);
      +        let html = render_to_html(&doc, |_| true, info_fn, |_| false, noop_embed);
               assert!(
                   html.contains("artifact-embed"),
                   "should contain embedded artifact card"
      @@ -1593,7 +1622,7 @@ See frontmatter.
           fn artifact_embedding_broken_ref() {
               let content = "---\nid: DOC-B\ntitle: Broken\n---\nSee {{artifact:NOPE-999}} here.\n";
               let doc = parse_document(content, None).unwrap();
      -        let html = render_to_html(&doc, |_| true, |_| None, |_| false);
      +        let html = render_to_html(&doc, |_| true, |_| None, |_| false, noop_embed);
               assert!(
                   html.contains("artifact-ref broken"),
                   "broken embed should have broken class"
      @@ -1641,7 +1670,7 @@ See frontmatter.
           fn embed_links_renders_outgoing_table() {
               let content = "---\nid: DOC-L\ntitle: Links\n---\n{{artifact:REQ-001:links}}\n";
               let doc = parse_document(content, None).unwrap();
      -        let html = render_to_html(&doc, |_| true, rich_info_fn, |_| false);
      +        let html = render_to_html(&doc, |_| true, rich_info_fn, |_| false, noop_embed);
               assert!(
                   html.contains("Outgoing Links"),
                   "should contain outgoing links heading"
      @@ -1658,7 +1687,7 @@ See frontmatter.
           fn embed_links_renders_incoming_table() {
               let content = "---\nid: DOC-L\ntitle: Links\n---\n{{artifact:REQ-001:links}}\n";
               let doc = parse_document(content, None).unwrap();
      -        let html = render_to_html(&doc, |_| true, rich_info_fn, |_| false);
      +        let html = render_to_html(&doc, |_| true, rich_info_fn, |_| false, noop_embed);
               assert!(
                   html.contains("Incoming Links"),
                   "should contain incoming links heading"
      @@ -1672,7 +1701,7 @@ See frontmatter.
           fn embed_full_shows_description_tags_fields_links() {
               let content = "---\nid: DOC-F\ntitle: Full\n---\n{{artifact:REQ-001:full}}\n";
               let doc = parse_document(content, None).unwrap();
      -        let html = render_to_html(&doc, |_| true, rich_info_fn, |_| false);
      +        let html = render_to_html(&doc, |_| true, rich_info_fn, |_| false, noop_embed);
               assert!(
                   html.contains("artifact-embed-full"),
                   "should have full class"
      @@ -1700,7 +1729,7 @@ See frontmatter.
           fn embed_upstream_renders_trace() {
               let content = "---\nid: DOC-U\ntitle: Upstream\n---\n{{artifact:REQ-001:upstream:2}}\n";
               let doc = parse_document(content, None).unwrap();
      -        let html = render_to_html(&doc, |_| true, rich_info_fn, |_| false);
      +        let html = render_to_html(&doc, |_| true, rich_info_fn, |_| false, noop_embed);
               assert!(
                   html.contains("artifact-embed-trace"),
                   "should have trace class"
      @@ -1722,7 +1751,7 @@ See frontmatter.
           fn embed_downstream_renders_trace() {
               let content = "---\nid: DOC-D\ntitle: Down\n---\n{{artifact:REQ-001:downstream:1}}\n";
               let doc = parse_document(content, None).unwrap();
      -        let html = render_to_html(&doc, |_| true, rich_info_fn, |_| false);
      +        let html = render_to_html(&doc, |_| true, rich_info_fn, |_| false, noop_embed);
               assert!(
                   html.contains("artifact-embed-trace"),
                   "should have trace class"
      @@ -1744,7 +1773,7 @@ See frontmatter.
           fn embed_chain_renders_both_directions() {
               let content = "---\nid: DOC-C\ntitle: Chain\n---\n{{artifact:REQ-001:chain}}\n";
               let doc = parse_document(content, None).unwrap();
      -        let html = render_to_html(&doc, |_| true, rich_info_fn, |_| false);
      +        let html = render_to_html(&doc, |_| true, rich_info_fn, |_| false, noop_embed);
               assert!(
                   html.contains("artifact-embed-chain"),
                   "should have chain class"
      @@ -1763,7 +1792,7 @@ See frontmatter.
           fn links_only_renders_tables_without_card_header() {
               let content = "---\nid: DOC-LO\ntitle: LinksOnly\n---\n{{links:REQ-001}}\n";
               let doc = parse_document(content, None).unwrap();
      -        let html = render_to_html(&doc, |_| true, rich_info_fn, |_| false);
      +        let html = render_to_html(&doc, |_| true, rich_info_fn, |_| false, noop_embed);
               assert!(
                   html.contains("artifact-embed-links-only"),
                   "should have links-only class"
      @@ -1788,7 +1817,7 @@ See frontmatter.
           fn unknown_modifier_falls_back_to_default() {
               let content = "---\nid: DOC-X\ntitle: Unknown\n---\n{{artifact:REQ-001:bogus}}\n";
               let doc = parse_document(content, None).unwrap();
      -        let html = render_to_html(&doc, |_| true, rich_info_fn, |_| false);
      +        let html = render_to_html(&doc, |_| true, rich_info_fn, |_| false, noop_embed);
               // Should fall back to the default card rendering
               assert!(
                   html.contains("artifact-embed"),
      @@ -1808,4 +1837,44 @@ See frontmatter.
                   "should not use trace rendering"
               );
           }
      +
      +    #[test]
      +    fn computed_embed_dispatches_to_resolver() {
      +        let content = "---\nid: DOC-X\ntitle: Embed Test\n---\nData: {{stats}}\n";
      +        let doc = parse_document(content, None).unwrap();
      +        let html = render_to_html(
      +            &doc,
      +            |_| true,
      +            |_| None,
      +            |_| false,
      +            |req| Ok(format!("
      stats:{}
      ", req.name)), + ); + assert!( + html.contains("mock-stats"), + "computed embed should be resolved via closure" + ); + assert!(html.contains("stats:stats"), "should pass correct embed name"); + } + + // SC-EMBED-3 + #[test] + fn unknown_embed_renders_visible_error() { + let content = "---\nid: DOC-U\ntitle: Unknown\n---\nSee {{bogus_embed}} here.\n"; + let doc = parse_document(content, None).unwrap(); + let html = render_to_html( + &doc, + |_| true, + |_| None, + |_| false, + |_| Err("Unknown embed: bogus_embed".to_string()), + ); + assert!( + html.contains("embed-error"), + "unknown embed must render visible error" + ); + assert!( + html.contains("bogus_embed"), + "error must show the unknown name" + ); + } } diff --git a/rivet-core/src/embed.rs b/rivet-core/src/embed.rs new file mode 100644 index 0000000..3235f37 --- /dev/null +++ b/rivet-core/src/embed.rs @@ -0,0 +1,572 @@ +//! Computed embed resolution for documents. +//! +//! Parses `{{name:arg1:arg2 key=val}}` syntax into `EmbedRequest` and +//! dispatches to type-specific renderers (stats, coverage, etc.). + +use std::collections::BTreeMap; +use std::fmt; +use std::fmt::Write as _; + +use crate::coverage; +use crate::document; + +// ── Types ─────────────────────────────────────────────────────────────── + +/// A parsed embed request extracted from `{{...}}` syntax. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EmbedRequest { + /// Embed type name: "stats", "coverage", "artifact", "links", "table", etc. + pub name: String, + /// Positional arguments (colon-separated after the name). + pub args: Vec, + /// Key=value options (space-separated after args). + pub options: BTreeMap, +} + +/// Error produced when an embed cannot be resolved. +#[derive(Debug, Clone)] +pub struct EmbedError { + pub kind: EmbedErrorKind, + pub raw_text: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EmbedErrorKind { + /// The embed name is not recognized. + UnknownEmbed(String), + /// The embed syntax is malformed. + MalformedSyntax(String), + /// The embed resolved but produced no data. + EmptyResult, + /// Parse error (empty input). + ParseError(String), +} + +impl fmt::Display for EmbedError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.kind { + EmbedErrorKind::UnknownEmbed(name) => write!(f, "Unknown embed: {name}"), + EmbedErrorKind::MalformedSyntax(msg) => write!(f, "Malformed embed: {msg}"), + EmbedErrorKind::EmptyResult => write!(f, "Embed produced no data"), + EmbedErrorKind::ParseError(msg) => write!(f, "Embed parse error: {msg}"), + } + } +} + +impl std::error::Error for EmbedError {} + +impl EmbedError { + /// Render this error as visible HTML (SC-EMBED-3). + pub fn to_error_html(&self) -> String { + let msg = document::html_escape(&self.to_string()); + format!("{msg}") + } +} + +// ── Context ───────────────────────────────────────────────────────────── + +use crate::links::LinkGraph; +use crate::schema::Schema; +use crate::store::Store; +use crate::validate::Diagnostic; + +/// Data context for embed resolution. +/// +/// Holds borrowed references to the project state. Callers construct +/// this from whatever state they have (AppState, export pipeline, CLI). +pub struct EmbedContext<'a> { + pub store: &'a Store, + pub schema: &'a Schema, + pub graph: &'a LinkGraph, + pub diagnostics: &'a [Diagnostic], +} + +impl<'a> EmbedContext<'a> { + /// Create an empty context for testing. + #[cfg(test)] + pub fn empty() -> Self { + use std::sync::LazyLock; + static EMPTY_STORE: LazyLock = LazyLock::new(Store::new); + static EMPTY_SCHEMA: LazyLock = LazyLock::new(|| Schema::merge(&[])); + static EMPTY_GRAPH: LazyLock = + LazyLock::new(|| LinkGraph::build(&EMPTY_STORE, &EMPTY_SCHEMA)); + Self { + store: &EMPTY_STORE, + schema: &EMPTY_SCHEMA, + graph: &EMPTY_GRAPH, + diagnostics: &[], + } + } +} + +// ── Parsing ───────────────────────────────────────────────────────────── + +impl EmbedRequest { + /// Parse a raw embed string (the content between `{{` and `}}`). + /// + /// Syntax: `name[:arg1[:arg2[...]]] [key=val ...]` + pub fn parse(input: &str) -> Result { + let input = input.trim(); + if input.is_empty() { + return Err(EmbedError { + kind: EmbedErrorKind::ParseError("empty embed".into()), + raw_text: String::new(), + }); + } + + // 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), + }; + + // 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(); + + // Parse key=val options + let mut options = BTreeMap::new(); + if let Some(opts_str) = options_part { + for token in opts_str.split_whitespace() { + if let Some((key, val)) = token.split_once('=') { + options.insert(key.to_string(), val.to_string()); + } + } + } + + Ok(EmbedRequest { + name, + args, + options, + }) + } + + /// True if this embed is a "legacy" type handled by existing + /// `resolve_inline` logic (artifact, links, table). + pub fn is_legacy(&self) -> bool { + matches!(self.name.as_str(), "artifact" | "links" | "table") + } +} + +// ── Resolution ────────────────────────────────────────────────────────── + +/// Resolve a computed embed to HTML. +/// +/// Returns the rendered HTML string, or an `EmbedError` for unknown/ +/// malformed embeds (SC-EMBED-3: errors are visible, never empty). +pub fn resolve_embed( + request: &EmbedRequest, + ctx: &EmbedContext<'_>, +) -> Result { + match request.name.as_str() { + "stats" => Ok(render_stats(request, ctx)), + "coverage" => Ok(render_coverage(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 { + kind: EmbedErrorKind::MalformedSyntax( + "artifact/links/table embeds are handled inline".into(), + ), + raw_text: format!("{request:?}"), + }), + other => Err(EmbedError { + kind: EmbedErrorKind::UnknownEmbed(other.to_string()), + raw_text: format!("{request:?}"), + }), + } +} + +// ── Stats renderer ────────────────────────────────────────────────────── + +/// Render `{{stats}}` / `{{stats:types}}` / `{{stats:status}}` / `{{stats:validation}}`. +fn render_stats(request: &EmbedRequest, ctx: &EmbedContext<'_>) -> String { + let section = request.args.first().map(|s| s.as_str()); + let mut html = String::from("
      \n"); + + let show_types = section.is_none() || section == Some("types"); + let show_status = section.is_none() || section == Some("status"); + let show_validation = section.is_none() || section == Some("validation"); + + if show_types { + html.push_str(&render_stats_types(ctx)); + } + if show_status { + html.push_str(&render_stats_status(ctx)); + } + if show_validation { + html.push_str(&render_stats_validation(ctx)); + } + + html.push_str("
      \n"); + html +} + +fn render_stats_types(ctx: &EmbedContext<'_>) -> String { + let mut by_type = BTreeMap::new(); + for type_name in ctx.schema.artifact_types.keys() { + by_type.insert(type_name.clone(), 0usize); + } + for artifact in ctx.store.iter() { + *by_type.entry(artifact.artifact_type.clone()).or_default() += 1; + } + let total: usize = by_type.values().sum(); + + let mut out = String::from( + "

    {text}
    {text}
    \n", + ); + for (typ, count) in &by_type { + if *count > 0 { + let _ = writeln!(out, ""); + } + } + let _ = writeln!( + out, + "" + ); + out.push_str("
    TypeCount
    {typ}{count}
    Total{total}
    \n"); + out +} + +fn render_stats_status(ctx: &EmbedContext<'_>) -> String { + let mut by_status: BTreeMap = BTreeMap::new(); + for artifact in ctx.store.iter() { + let key = artifact.status.as_deref().unwrap_or("unset").to_string(); + *by_status.entry(key).or_default() += 1; + } + + let mut out = String::from( + "\n", + ); + for (status, count) in &by_status { + let _ = writeln!(out, ""); + } + out.push_str("
    StatusCount
    {status}{count}
    \n"); + out +} + +fn render_stats_validation(ctx: &EmbedContext<'_>) -> String { + use crate::schema::Severity; + let mut worst: BTreeMap = BTreeMap::new(); + for diag in ctx.diagnostics { + if let Some(ref id) = diag.artifact_id { + let entry = worst.entry(id.clone()).or_insert(Severity::Info); + if severity_rank(diag.severity) > severity_rank(*entry) { + *entry = diag.severity; + } + } + } + let (mut errors, mut warnings, mut infos, mut clean) = (0usize, 0, 0, 0); + for artifact in ctx.store.iter() { + match worst.get(&artifact.id) { + Some(Severity::Error) => errors += 1, + Some(Severity::Warning) => warnings += 1, + Some(Severity::Info) => infos += 1, + None => clean += 1, + } + } + + let mut out = String::from( + "\n", + ); + let _ = writeln!(out, ""); + let _ = writeln!(out, ""); + let _ = writeln!(out, ""); + let _ = writeln!(out, ""); + out.push_str("
    SeverityArtifacts
    Error{errors}
    Warning{warnings}
    Info{infos}
    Clean{clean}
    \n"); + out +} + +fn severity_rank(s: crate::schema::Severity) -> u8 { + match s { + crate::schema::Severity::Info => 1, + crate::schema::Severity::Warning => 2, + crate::schema::Severity::Error => 3, + } +} + +// ── Coverage renderer ─────────────────────────────────────────────────── + +/// Render `{{coverage}}` or `{{coverage:RULE_NAME}}`. +fn render_coverage(request: &EmbedRequest, ctx: &EmbedContext<'_>) -> String { + let report = coverage::compute_coverage(ctx.store, ctx.schema, ctx.graph); + let filter_rule = request.args.first().map(|s| s.as_str()); + + let entries: Vec<_> = report + .entries + .iter() + .filter(|e| filter_rule.is_none_or(|r| e.rule_name == r)) + .collect(); + + if entries.is_empty() { + return "

    No coverage rules defined.

    \n".to_string(); + } + + let mut html = String::from( + "
    \n\ + \ + \ + \n", + ); + + for entry in &entries { + let pct = entry.percentage(); + let bar_width = pct.round() as u32; + let bar_class = if pct >= 100.0 { + "bar-full" + } else if pct >= 80.0 { + "bar-good" + } else if pct >= 50.0 { + "bar-warn" + } else { + "bar-danger" + }; + let _ = writeln!( + html, + "\ + \ + \ + \ + \ + \ + \ + ", + rule = entry.rule_name, + source = entry.source_type, + covered = entry.covered, + total = entry.total, + ); + } + + html.push_str("
    RuleSourceCoveredTotal%Bar
    {rule}{source}{covered}{total}{pct:.1}%
    \n"); + + // If filtering to a single rule, show uncovered IDs + if filter_rule.is_some() { + for entry in &entries { + if !entry.uncovered_ids.is_empty() { + html.push_str("
    Uncovered artifacts
      \n"); + for id in &entry.uncovered_ids { + let _ = writeln!(html, "
    • {id}
    • "); + } + html.push_str("
    \n"); + } + } + } + + html.push_str("
    \n"); + html +} + +// ── Provenance ────────────────────────────────────────────────────────── + +/// Render a provenance footer for export (SC-EMBED-4). +/// +/// Every computed embed in export must include the commit hash and timestamp +/// so reviewers can trace exactly what code produced the exported data. +pub fn render_provenance_stamp(commit_short: &str, is_dirty: bool) -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + // Convert epoch seconds to a human-readable UTC timestamp. + let (year, month, day, hours, minutes) = epoch_to_ymd_hm(timestamp); + let dirty_note = if is_dirty { " (dirty)" } else { "" }; + + format!( + "
    Computed at {year}-{month:02}-{day:02} {hours:02}:{minutes:02} UTC from commit {commit_short}{dirty_note}
    \n" + ) +} + +/// Convert seconds since Unix epoch to (year, month, day, hour, minute) in UTC. +fn epoch_to_ymd_hm(secs: u64) -> (u64, u64, u64, u64, u64) { + let days = secs / 86400; + let time_of_day = secs % 86400; + let hours = time_of_day / 3600; + let minutes = (time_of_day % 3600) / 60; + + // Algorithm from Howard Hinnant's civil_from_days (public domain). + let z = days + 719468; + let era = z / 146097; + let doe = z - era * 146097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + + (y, m, d, hours, minutes) +} + +// ── Tests ─────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_bare_name() { + let req = EmbedRequest::parse("stats").unwrap(); + assert_eq!(req.name, "stats"); + assert!(req.args.is_empty()); + assert!(req.options.is_empty()); + } + + #[test] + fn parse_name_with_args() { + let req = EmbedRequest::parse("stats:types").unwrap(); + assert_eq!(req.name, "stats"); + assert_eq!(req.args, vec!["types"]); + } + + #[test] + fn parse_name_with_multiple_args() { + let req = EmbedRequest::parse("matrix:requirement:feature").unwrap(); + assert_eq!(req.name, "matrix"); + assert_eq!(req.args, vec!["requirement", "feature"]); + } + + #[test] + fn parse_name_with_options() { + let req = EmbedRequest::parse("stats delta=v0.3.0").unwrap(); + assert_eq!(req.name, "stats"); + assert!(req.args.is_empty()); + assert_eq!(req.options.get("delta"), Some(&"v0.3.0".to_string())); + } + + #[test] + fn parse_args_and_options() { + let req = EmbedRequest::parse("coverage:req-implements-feat delta=v0.3.0").unwrap(); + assert_eq!(req.name, "coverage"); + assert_eq!(req.args, vec!["req-implements-feat"]); + assert_eq!(req.options.get("delta"), Some(&"v0.3.0".to_string())); + } + + #[test] + fn parse_empty_returns_error() { + assert!(EmbedRequest::parse("").is_err()); + assert!(EmbedRequest::parse(" ").is_err()); + } + + #[test] + fn stats_embed_renders_html_table() { + let ctx = EmbedContext::empty(); // empty store/schema → still renders a table + let req = EmbedRequest::parse("stats").unwrap(); + let html = resolve_embed(&req, &ctx).unwrap(); + assert!(html.contains(" String { let timestamp = timestamp_now(); @@ -2098,7 +2103,9 @@ pub fn render_document_page( // Render the document body with resolved links for static export. let req_href = "./requirements.html"; - let body_html = render_document_body_for_export(doc, store, graph, req_href); + let body_html = render_document_body_for_export( + doc, store, graph, schema, diagnostics, commit_short, is_dirty, req_href, + ); out.push_str("
    \n"); out.push_str(&body_html); out.push_str("
    \n"); @@ -2113,10 +2120,15 @@ pub fn render_document_page( /// This wraps `document::render_to_html` but overrides the `[[ID]]` link /// resolution to point at `./requirements.html#art-ID` instead of HTMX /// endpoints, making it suitable for static sites. +#[allow(clippy::too_many_arguments)] fn render_document_body_for_export( doc: &document::Document, store: &Store, graph: &LinkGraph, + schema: &Schema, + diagnostics: &[Diagnostic], + commit_short: &str, + is_dirty: bool, req_href: &str, ) -> String { // Use the document module's render_to_html with custom callbacks. @@ -2183,7 +2195,22 @@ fn render_document_body_for_export( }; // Get the rendered HTML from the document module. - let raw_html = document::render_to_html(doc, artifact_exists, artifact_info, |_| false); + // Computed embeds are resolved with provenance stamps (SC-EMBED-4). + let raw_html = document::render_to_html(doc, artifact_exists, artifact_info, |_| false, |req| { + let embed_ctx = crate::embed::EmbedContext { + store, + schema, + graph, + diagnostics, + }; + match crate::embed::resolve_embed(req, &embed_ctx) { + Ok(html) => { + let stamp = crate::embed::render_provenance_stamp(commit_short, is_dirty); + Ok(format!("{html}{stamp}")) + } + Err(e) => Err(e.to_string()), + } + }); // Post-process: rewrite the HTMX-style artifact links to static links. // The document renderer produces: @@ -3133,7 +3160,7 @@ mod tests { None, ) .unwrap(); - let html = render_document_page(&doc, &store, &graph, &cfg); + let html = render_document_page(&doc, &store, &graph, &_schema, &[], "abc1234", false, &cfg); assert!(html.contains("DOC-001")); assert!(html.contains("Design Doc")); assert!(html.contains("Design")); diff --git a/rivet-core/src/lib.rs b/rivet-core/src/lib.rs index 58de74c..103ff5b 100644 --- a/rivet-core/src/lib.rs +++ b/rivet-core/src/lib.rs @@ -5,6 +5,7 @@ pub mod coverage; pub mod db; pub mod diff; pub mod document; +pub mod embed; pub mod embedded; pub mod error; pub mod export; diff --git a/rivet-core/tests/integration.rs b/rivet-core/tests/integration.rs index 840e927..6817ff4 100644 --- a/rivet-core/tests/integration.rs +++ b/rivet-core/tests/integration.rs @@ -1116,7 +1116,7 @@ fn document_with_aadl_block_renders_placeholder() { let doc = rivet_core::document::parse_document(doc_content, None).unwrap(); let html = - rivet_core::document::render_to_html(&doc, |id| id == "SYSREQ-001", |_| None, |_| false); + rivet_core::document::render_to_html(&doc, |id| id == "SYSREQ-001", |_| None, |_| false, |_| Ok(String::new())); // AADL block becomes a diagram placeholder assert!(html.contains("class=\"aadl-diagram\"")); diff --git a/tests/playwright/documents.spec.ts b/tests/playwright/documents.spec.ts index 3462106..8dd49ad 100644 --- a/tests/playwright/documents.spec.ts +++ b/tests/playwright/documents.spec.ts @@ -117,4 +117,51 @@ test.describe("Documents", () => { console.log("No .doc-ref links found — no cross-document references yet"); } }); + + test("computed embeds do not produce visible errors", async ({ page }) => { + // Visit each document and verify no embed-error spans are present. + await page.goto("/documents"); + await waitForHtmx(page); + const hxPaths = await page + .locator("a[href^='/documents/']") + .evaluateAll((els) => + els.map((el) => el.getAttribute("href")).filter(Boolean), + ); + + for (const path of hxPaths as string[]) { + await page.goto(path); + await waitForHtmx(page); + const errorCount = await page.locator(".embed-error").count(); + expect(errorCount).toBe( + 0, + `document ${path} should have no embed-error spans`, + ); + } + }); + + test("embed-stats renders a table when present", async ({ page }) => { + // Visit each document looking for embed-stats divs + await page.goto("/documents"); + await waitForHtmx(page); + const hxPaths = await page + .locator("a[href^='/documents/']") + .evaluateAll((els) => + els.map((el) => el.getAttribute("href")).filter(Boolean), + ); + + for (const path of hxPaths as string[]) { + await page.goto(path); + await waitForHtmx(page); + const statsCount = await page.locator(".embed-stats").count(); + if (statsCount > 0) { + // If a stats embed exists, it should contain a table + await expect(page.locator(".embed-stats table").first()).toBeVisible(); + return; + } + } + // No stats embeds found — test passes vacuously + console.log( + "No .embed-stats found in documents — add {{stats}} to a doc to test", + ); + }); });