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.
This commit is contained in:
1 parent
22f32e6fc5
commit
e361b4181a
2 files changed
+261
-11
No files matched your search
+184
-5
@@ -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<T: serde::Serialize>(&self, name: &str, data: &T) -> Result<String, Error> {
|
||||
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<T: serde::Serialize>(&self, name: &str, context: &T) -> Result<String, Error> {
|
||||
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<T: serde::Serialize, W: 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(_)));
|
||||
}
|
||||
}
|
||||
@@ -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<T: serde::Serialize>(name, context) ->
|
||||
Result<String, Error>` and `render_to_write<T: serde::Serialize,
|
||||
W: std::io::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<RenderErrorReason> 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.
|
||||
Reference in new issue
Block a user