From e361b4181ac9e7635a5c1168b37d4ef26eeb6494 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Fri, 6 Nov 2026 20:01:57 +0000 Subject: [PATCH] feat(render): add the TemplateSet render entry point (both sinks, deterministic, strict) - public TemplateSet::render (String sink) and TemplateSet::render_to_write (io::Write sink), synchronous and deterministic, both surfacing the wave-1 Error enum - render-name rule enforced behaviorally: the entry resolves names against the set-level bookkeeping before the substrate is consulted, so a partial's name or an unknown name is a typed Error::Render (substrate TemplateNotFound reason), never a rendered partial - strict-mode missing variable is a typed error in both sinks, never silently-empty output; the write sink's streaming (non-atomic) semantics on mid-render failure documented - determinism pinned by tests: byte-identical repeated renders, byte-identical output across registry rebuilds in opposite registration order, and byte-for-byte agreement between sinks Verification: cargo test (19 passed), cargo clippy --all-targets -- -D warnings, cargo fmt --check, cargo doc --no-deps, and cargo build --target wasm32-unknown-unknown --release all green. --- src/registry.rs | 189 +++++++++++++++++++++++- tasks/implementation/w1-render-entry.md | 83 ++++++++++- 2 files changed, 261 insertions(+), 11 deletions(-) diff --git a/src/registry.rs b/src/registry.rs index e2aa507..0723e1c 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -1,9 +1,12 @@ //! Registry construction: one strict registry per template set -//! (core.md §Registry construction). +//! (core.md §Registry construction), plus the render entry point +//! (core.md §Render entry point): the two-sink surface every consumer +//! calls. use std::collections::HashSet; +use std::io::Write; -use handlebars::{no_escape, Handlebars, HelperDef}; +use handlebars::{no_escape, Handlebars, HelperDef, RenderError, RenderErrorReason}; use crate::error::Error; use crate::escape::EscapePolicy; @@ -101,9 +104,69 @@ impl TemplateSet { self.template_names.contains(name) } - #[cfg(test)] - fn render(&self, name: &str, data: &T) -> Result { - Ok(self.handlebars.render(name, data)?) + /// Render a registered template over a serde-serializable context + /// into a String (core.md §Render entry point). + /// + /// The name resolves only against templates: a name registered as a + /// partial (or never registered) is rejected as a typed + /// [`Error::Render`] before the substrate is consulted, because + /// partials exist to be composed inside templates, never rendered + /// directly (core.md §Template assets). + /// + /// Rendering is synchronous, pure, and deterministic: same registry + /// contents plus same context produce byte-identical text (ADR-003). + /// There is no I/O, no time read, and no hash-map iteration order + /// that can leak into the output. + /// + /// Strict mode (forced at construction) makes a missing context + /// variable a typed [`Error::Render`] through the public enum, never + /// silently-empty output (ADR-002 decision 2). The substrate's + /// [`RenderError`] stays reachable through the source chain. + pub fn render(&self, name: &str, context: &T) -> Result { + self.resolve_render_target(name)?; + Ok(self.handlebars.render(name, context)?) + } + + /// Render a registered template over a serde-serializable context + /// into an [`std::io::Write`] target (core.md §Render entry point). + /// + /// Same contract as [`TemplateSet::render`], with a streaming sink: + /// a large page or a large generated file need not force an + /// intermediate allocation. Both sinks are wasm-clean. + /// + /// Because the substrate streams to the writer incrementally, a + /// render that fails partway (e.g. a strict-mode missing variable + /// after some output was already produced) may leave bytes written + /// to the target before the typed [`Error::Render`] is returned. + /// Consumers that need atomicity buffer first; the sink exists for + /// the streaming case. + pub fn render_to_write( + &self, + name: &str, + context: &T, + writer: W, + ) -> Result<(), Error> { + self.resolve_render_target(name)?; + self.handlebars.render_to_write(name, context, writer)?; + Ok(()) + } + + /// Resolve the render-name rule: the name argument must be + /// registered as a template, never a bare partial. + /// + /// The substrate cannot enforce this (its registry is one namespace + /// where a registered partial is indistinguishable from a template), + /// so the resolution consults the set-level bookkeeping + /// ([`TemplateSet::is_template`]) before the substrate is touched. + /// Both a partial's name and a never-registered name surface the + /// substrate's `TemplateNotFound` reason wrapped in the public + /// [`Error::Render`] variant. + fn resolve_render_target(&self, name: &str) -> Result<(), Error> { + if self.template_names.contains(name) { + return Ok(()); + } + let render_error = RenderError::from(RenderErrorReason::TemplateNotFound(name.to_owned())); + Err(Error::from(render_error)) } } @@ -272,4 +335,120 @@ mod tests { .expect_err("unregistered helper must fail, never render silently"); assert!(matches!(err, Error::Render(_))); } + + fn deterministic_fixture_first() -> TemplateSet { + let mut set = TemplateSet::new(EscapePolicy::NoOp); + set.register_template( + "readme", + "# {{title}}\n{{#each items}}- {{{this}}}\n{{/each}}{{> footer}}", + ) + .expect("fixture template should be well-formed"); + set.register_partial("footer", "footer: {{title}}") + .expect("fixture partial should be well-formed"); + set + } + + fn deterministic_fixture_second() -> TemplateSet { + let mut set = TemplateSet::new(EscapePolicy::NoOp); + set.register_partial("footer", "footer: {{title}}") + .expect("fixture partial should be well-formed"); + set.register_template( + "readme", + "# {{title}}\n{{#each items}}- {{{this}}}\n{{/each}}{{> footer}}", + ) + .expect("fixture template should be well-formed"); + set + } + + fn deterministic_context() -> serde_json::Value { + json!({ + "title": "sample", + "items": ["alpha", "beta", "gamma"] + }) + } + + #[test] + fn render_is_deterministic_repeated_and_across_registry_rebuilds() { + let set_a = deterministic_fixture_first(); + let set_b = deterministic_fixture_second(); + let context = deterministic_context(); + + let first = set_a + .render("readme", &context) + .expect("fixture should render"); + for _ in 0..4 { + let again = set_a + .render("readme", &context) + .expect("fixture should render again"); + assert_eq!(again, first, "repeated renders must be byte-identical"); + } + + let rebuilt = set_b + .render("readme", &context) + .expect("rebuilt fixture should render"); + assert_eq!( + rebuilt, first, + "same registry contents rebuilt must render byte-identical text" + ); + } + + #[test] + fn write_sink_matches_string_sink_byte_for_byte() { + let set = deterministic_fixture_first(); + let context = deterministic_context(); + let expected = set + .render("readme", &context) + .expect("fixture should render"); + + let mut sink = Vec::new(); + set.render_to_write("readme", &context, &mut sink) + .expect("write sink should render"); + + assert_eq!(&sink[..], expected.as_bytes()); + } + + #[test] + fn strict_missing_variable_is_a_typed_error_in_both_sinks() { + let mut set = TemplateSet::new(EscapePolicy::NoOp); + set.register_template("greeting", "hello {{name}}") + .expect("fixture template should be well-formed"); + let context = json!({}); + + let string_err = set + .render("greeting", &context) + .expect_err("string sink must fail under strict mode, never render empty output"); + assert!(matches!(string_err, Error::Render(_))); + + let mut sink = Vec::new(); + let write_err = set + .render_to_write("greeting", &context, &mut sink) + .expect_err("write sink must fail under strict mode, never render empty output"); + assert!(matches!(write_err, Error::Render(_))); + + let present = set + .render("greeting", &json!({ "name": "world" })) + .expect("present variable should still render"); + assert_eq!(present, "hello world"); + } + + #[test] + fn render_by_partial_name_and_unknown_name_are_rejected_typed() { + let set = deterministic_fixture_first(); + + let partial_err = set + .render("footer", &deterministic_context()) + .expect_err("rendering by a partial's name must be rejected"); + assert!(matches!(partial_err, Error::Render(_))); + + let unknown_err = set + .render("no-such-template", &deterministic_context()) + .expect_err("rendering an unregistered name must be rejected"); + assert!(matches!(unknown_err, Error::Render(_))); + + let mut sink = Vec::new(); + let partial_err = set + .render_to_write("footer", &deterministic_context(), &mut sink) + .expect_err("write sink must reject a partial's name too"); + assert!(matches!(partial_err, Error::Render(_))); + } } diff --git a/tasks/implementation/w1-render-entry.md b/tasks/implementation/w1-render-entry.md index c19a74a..6517d24 100644 --- a/tasks/implementation/w1-render-entry.md +++ b/tasks/implementation/w1-render-entry.md @@ -1,7 +1,7 @@ --- id: w1-render-entry name: "Wave 1: the render entry point (both sinks, deterministic, strict)" -status: pending +status: completed depends_on: [w1-registry-construction] scope: narrow risk: low @@ -37,15 +37,15 @@ component every consumer actually calls: ## Acceptance Criteria -- [ ] String-sink and `io::Write`-sink render entry points, both +- [x] String-sink and `io::Write`-sink render entry points, both surfacing the public error enum -- [ ] determinism pinned by tests (byte-identical repeated renders, +- [x] determinism pinned by tests (byte-identical repeated renders, including across registry rebuilds) -- [ ] strict-mode missing-variable render is a typed error in both +- [x] strict-mode missing-variable render is a typed error in both sinks, never empty output -- [ ] render-by-partial behavior matches the registry task's recorded +- [x] render-by-partial behavior matches the registry task's recorded decision and is pinned by a test -- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`, +- [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` pass ## References @@ -63,6 +63,77 @@ component every consumer actually calls: > Agent fills this during implementation. +**Landed surface.** Two public methods on `TemplateSet` +(`src/registry.rs`): `render(name, context) -> +Result` and `render_to_write(name, context, writer) -> Result<(), Error>`. Both +are synchronous, `&self`, and wrap the substrate's `render` / +`render_to_write` (registry.rs:786, :804 in the vendored checkout); +both surface the wave-1 `Error` enum, mapping the substrate +`RenderError` through the existing `#[from]` arm. No new dependency, no +new error variant, no change to `lib.rs` re-exports (the entry points +are methods on the already-re-exported `TemplateSet`). The +`#[cfg(test)]` render shim the registry task left behind is removed; +the earlier registry tests now exercise the public entry. + +**Render-name rule enforcement (the registry task's decision 1, +behavioral half).** The substrate cannot enforce the rule +(`register_partial` is literally `register_template_string`, its +registry is one namespace), so the render entry resolves the name +against the set-level bookkeeping (`template_names`) before the +substrate is consulted. A name registered as a partial, or never +registered, is rejected as `Error::Render` wrapping the substrate's +`RenderErrorReason::TemplateNotFound`, constructed through the public +non-deprecated path (`From for RenderError`). +Unknown names surface through the same guard, which also removes any +chance of the substrate's own template-not-found path diverging. +Pinned by `render_by_partial_name_and_unknown_name_are_rejected_typed`, +covering both sinks. + +**Strict errors through both sinks.** A missing context variable fails +both sinks as `Error::Render`, never silent empty output: pinned by +`strict_missing_variable_is_a_typed_error_in_both_sinks` (the string +sink must return `Err`, never an empty `Ok`). Recorded interpretation +about the write sink: the substrate streams to the writer +incrementally, so a render that fails partway may leave bytes already +written to the target before the typed error returns. This is +documented in `render_to_write`'s doc comment as streaming semantics +(consumers wanting atomicity buffer first); the test pins the typed +error, not the exact partial byte count, to avoid pinning substrate +internals. + +**Determinism.** Pinned by two tests: a fixture set (template + +partial + `{{#each}}` loop) renders byte-identical across five +repeated renders and across a rebuilt registry where the same +registrations land in the opposite order (no registration order leaks +into output); the write sink agrees with the string sink byte for +byte. No time, no I/O, no hash-map iteration order in the path: the +only additions are two method bodies delegating to the substrate plus +the name guard. + +**Verification (all green).** `cargo test` (19 passed), +`cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`, +`cargo doc --no-deps` (warning-free; one broken intra-doc link during +development fixed), `cargo build --target wasm32-unknown-unknown +--release`. No em dashes in the added prose. + ## Summary > Agent fills this on completion. + +Render entry point landed as the public surface of `TemplateSet` +(src/registry.rs): `render` (String sink) and `render_to_write` +(`io::Write` sink), synchronous and deterministic, both surfacing the +wave-1 `Error` enum. The render-name rule from the registry task's +recorded mechanism is now behavior: the entry resolves names against +the set-level bookkeeping before the substrate is consulted, so a +partial's name (or a never-registered name) is a typed +`Error::Render` in both sinks, never a rendered partial. Strict mode +makes a missing variable a typed error in both sinks, never empty +output; the write sink's streaming (non-atomic) semantics on mid-render +failure are documented at the method. Determinism is pinned by tests: +byte-identical repeated renders, byte-identical output across a +rebuilt registry registering in the opposite order, and byte-for-byte +agreement between the two sinks. Verification: 19 tests pass, clippy +`-D warnings` clean, `fmt --check` clean, `cargo doc --no-deps` +clean, wasm release build to `wasm32-unknown-unknown` clean.