Skip to main content

qa_spec/template/
mod.rs

1use crate::secrets::{SecretAccessResult, SecretAction, evaluate};
2use crate::spec::form::{FormSpec, SecretsPolicy};
3use handlebars::{
4    Context, Handlebars, Helper, HelperResult, Output, RenderContext, RenderError,
5    RenderErrorReason,
6};
7use serde_json::{Map, Value};
8use thiserror::Error;
9
10/// Modes describing how missing values are handled.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ResolutionMode {
13    /// Missing values emit an error.
14    Strict,
15    /// Missing values leave handlebars tokens untouched.
16    Relaxed,
17}
18
19/// Context passed into templates.
20#[derive(Debug, Clone)]
21pub struct TemplateContext {
22    pub payload: Value,
23    pub state: Value,
24    pub config: Value,
25    pub answers: Value,
26    pub secrets: Option<SecretsContext>,
27}
28
29impl Default for TemplateContext {
30    fn default() -> Self {
31        let empty = Value::Object(Map::new());
32        Self {
33            payload: empty.clone(),
34            state: empty.clone(),
35            config: empty.clone(),
36            answers: empty,
37            secrets: None,
38        }
39    }
40}
41
42impl TemplateContext {
43    /// Replace the payload value.
44    pub fn with_payload(mut self, payload: Value) -> Self {
45        self.payload = payload;
46        self
47    }
48
49    /// Replace the state value.
50    pub fn with_state(mut self, state: Value) -> Self {
51        self.state = state;
52        self
53    }
54
55    /// Replace the config value.
56    pub fn with_config(mut self, config: Value) -> Self {
57        self.config = config;
58        self
59    }
60
61    /// Replace answers.
62    pub fn with_answers(mut self, answers: Value) -> Self {
63        self.answers = answers;
64        self
65    }
66
67    /// Set optional secrets with policy metadata.
68    pub fn with_secrets(
69        mut self,
70        secrets: Value,
71        policy: Option<SecretsPolicy>,
72        host_available: bool,
73    ) -> Self {
74        self.secrets = Some(SecretsContext::new(secrets, policy, host_available));
75        self
76    }
77
78    fn to_value(&self) -> Value {
79        let mut map = Map::new();
80        map.insert("payload".into(), self.payload.clone());
81        map.insert("state".into(), self.state.clone());
82        map.insert("config".into(), self.config.clone());
83        map.insert("answers".into(), self.answers.clone());
84        if let Some(secrets) = &self.secrets {
85            map.insert("secrets".into(), secrets.value());
86            map.insert("__secrets_meta".into(), secrets.meta());
87        }
88        Value::Object(map)
89    }
90}
91
92fn render_error(message: impl Into<String>) -> RenderError {
93    RenderErrorReason::Other(message.into()).into()
94}
95
96#[derive(Debug, Clone)]
97pub struct SecretsContext {
98    values: Map<String, Value>,
99    denied: Map<String, Value>,
100    host_available: bool,
101}
102
103impl SecretsContext {
104    fn new(secrets: Value, policy: Option<SecretsPolicy>, host_available: bool) -> Self {
105        let mut values = Map::new();
106        let mut denied = Map::new();
107
108        if let Some(map) = secrets.as_object() {
109            for (key, value) in map {
110                match evaluate(policy.as_ref(), key, SecretAction::Read, host_available) {
111                    SecretAccessResult::Allowed => {
112                        values.insert(key.clone(), value.clone());
113                    }
114                    SecretAccessResult::Denied(code) => {
115                        denied.insert(key.clone(), Value::String(code.into()));
116                    }
117                    SecretAccessResult::HostUnavailable => {
118                        denied.insert(key.clone(), Value::String("secret_host_unavailable".into()));
119                    }
120                }
121            }
122        }
123
124        Self {
125            values,
126            denied,
127            host_available,
128        }
129    }
130
131    fn value(&self) -> Value {
132        Value::Object(self.values.clone())
133    }
134
135    fn meta(&self) -> Value {
136        let mut meta = Map::new();
137        meta.insert("host_available".into(), Value::Bool(self.host_available));
138        meta.insert("denied".into(), Value::Object(self.denied.clone()));
139        Value::Object(meta)
140    }
141}
142
143/// Errors raised while resolving templates.
144#[derive(Debug, Error)]
145pub enum TemplateError {
146    #[error("template render error: {0}")]
147    Render(String),
148}
149
150/// Handlebars-based template engine for QA specs.
151pub struct TemplateEngine {
152    handlebars: Handlebars<'static>,
153    mode: ResolutionMode,
154}
155
156impl TemplateEngine {
157    /// Construct a templating engine.
158    pub fn new(mode: ResolutionMode) -> Self {
159        let mut handlebars = Handlebars::new();
160        register_default_helpers(&mut handlebars);
161        handlebars.set_strict_mode(true);
162        Self { handlebars, mode }
163    }
164
165    /// Resolve a string field using the provided context.
166    pub fn resolve_string(
167        &self,
168        template: &str,
169        ctx: &TemplateContext,
170    ) -> Result<String, TemplateError> {
171        match self.handlebars.render_template(template, &ctx.to_value()) {
172            Ok(result) => Ok(result),
173            Err(err) => match self.mode {
174                ResolutionMode::Relaxed => Ok(template.to_owned()),
175                ResolutionMode::Strict => Err(TemplateError::Render(err.to_string())),
176            },
177        }
178    }
179
180    /// Resolve templated strings within a `FormSpec`.
181    pub fn resolve_form_spec(
182        &self,
183        spec: &FormSpec,
184        ctx: &TemplateContext,
185    ) -> Result<FormSpec, TemplateError> {
186        let mut resolved = spec.clone();
187        resolved.title = self.resolve_string(&spec.title, ctx)?;
188        resolved.description = spec
189            .description
190            .as_ref()
191            .map(|value| self.resolve_string(value, ctx))
192            .transpose()?;
193
194        resolved.presentation = if let Some(presentation) = &spec.presentation {
195            let mut next = presentation.clone();
196            next.intro = presentation
197                .intro
198                .as_ref()
199                .map(|value| self.resolve_string(value, ctx))
200                .transpose()?;
201            next.theme = presentation
202                .theme
203                .as_ref()
204                .map(|value| self.resolve_string(value, ctx))
205                .transpose()?;
206            Some(next)
207        } else {
208            None
209        };
210
211        resolved.questions = spec
212            .questions
213            .iter()
214            .map(|question| {
215                let mut updated = question.clone();
216                updated.title = self.resolve_string(&question.title, ctx)?;
217                updated.description = question
218                    .description
219                    .as_ref()
220                    .map(|value| self.resolve_string(value, ctx))
221                    .transpose()?;
222                updated.default_value = question
223                    .default_value
224                    .as_ref()
225                    .map(|value| self.resolve_string(value, ctx))
226                    .transpose()?;
227                Ok(updated)
228            })
229            .collect::<Result<Vec<_>, TemplateError>>()?;
230
231        Ok(resolved)
232    }
233}
234
235pub fn register_default_helpers(handlebars: &mut Handlebars<'static>) {
236    handlebars.register_helper("get", Box::new(helper_get));
237    handlebars.register_helper("default", Box::new(helper_default));
238    handlebars.register_helper("eq", Box::new(helper_eq));
239    handlebars.register_helper("and", Box::new(helper_and));
240    handlebars.register_helper("or", Box::new(helper_or));
241    handlebars.register_helper("not", Box::new(helper_not));
242    handlebars.register_helper("len", Box::new(helper_len));
243    handlebars.register_helper("json", Box::new(helper_json));
244    handlebars.register_helper("secret", Box::new(helper_secret));
245}
246
247fn helper_get(
248    h: &Helper,
249    _: &Handlebars,
250    ctx: &Context,
251    _: &mut RenderContext,
252    out: &mut dyn Output,
253) -> HelperResult {
254    let path = h
255        .param(0)
256        .and_then(|param| param.value().as_str())
257        .ok_or_else(|| render_error("get helper requires a path"))?;
258    let pointer = to_pointer(path);
259    let root = ctx.data();
260    let value = root
261        .pointer(&pointer)
262        .map(value_to_string)
263        .or_else(|| h.param(1).map(|param| value_to_string(param.value())))
264        .unwrap_or_default();
265    out.write(&value)?;
266    Ok(())
267}
268
269fn helper_default(
270    h: &Helper,
271    _: &Handlebars,
272    _: &Context,
273    _: &mut RenderContext,
274    out: &mut dyn Output,
275) -> HelperResult {
276    let first = h.param(0).map(|param| param.value());
277    let fallback = h.param(1).map(|param| param.value());
278    let chosen = if let Some(value) = first {
279        if is_truthy(value) {
280            value_to_string(value)
281        } else {
282            fallback.map(value_to_string).unwrap_or_default()
283        }
284    } else {
285        fallback.map(value_to_string).unwrap_or_default()
286    };
287    out.write(&chosen)?;
288    Ok(())
289}
290
291fn helper_eq(
292    h: &Helper,
293    _: &Handlebars,
294    _: &Context,
295    _: &mut RenderContext,
296    out: &mut dyn Output,
297) -> HelperResult {
298    let left = h
299        .param(0)
300        .map(|param| param.value())
301        .unwrap_or(&Value::Null);
302    let right = h
303        .param(1)
304        .map(|param| param.value())
305        .unwrap_or(&Value::Null);
306    let result = left == right;
307    out.write(&result.to_string())?;
308    Ok(())
309}
310
311fn helper_and(
312    h: &Helper,
313    _: &Handlebars,
314    _: &Context,
315    _: &mut RenderContext,
316    out: &mut dyn Output,
317) -> HelperResult {
318    let mut truthy = true;
319    for param in h.params() {
320        truthy &= is_truthy(param.value());
321        if !truthy {
322            break;
323        }
324    }
325    out.write(&truthy.to_string())?;
326    Ok(())
327}
328
329fn helper_or(
330    h: &Helper,
331    _: &Handlebars,
332    _: &Context,
333    _: &mut RenderContext,
334    out: &mut dyn Output,
335) -> HelperResult {
336    let mut truthy = false;
337    for param in h.params() {
338        if is_truthy(param.value()) {
339            truthy = true;
340            break;
341        }
342    }
343    out.write(&truthy.to_string())?;
344    Ok(())
345}
346
347fn helper_not(
348    h: &Helper,
349    _: &Handlebars,
350    _: &Context,
351    _: &mut RenderContext,
352    out: &mut dyn Output,
353) -> HelperResult {
354    let value = h
355        .param(0)
356        .map(|param| param.value())
357        .unwrap_or(&Value::Bool(false));
358    out.write(&(!is_truthy(value)).to_string())?;
359    Ok(())
360}
361
362fn helper_len(
363    h: &Helper,
364    _: &Handlebars,
365    _: &Context,
366    _: &mut RenderContext,
367    out: &mut dyn Output,
368) -> HelperResult {
369    let value = h
370        .param(0)
371        .map(|param| param.value())
372        .unwrap_or(&Value::Null);
373    let len = match value {
374        Value::String(s) => s.len(),
375        Value::Array(arr) => arr.len(),
376        Value::Object(obj) => obj.len(),
377        _ => 0,
378    };
379    out.write(&len.to_string())?;
380    Ok(())
381}
382
383fn helper_json(
384    h: &Helper,
385    _: &Handlebars,
386    _: &Context,
387    _: &mut RenderContext,
388    out: &mut dyn Output,
389) -> HelperResult {
390    let value = h
391        .param(0)
392        .map(|param| param.value())
393        .unwrap_or(&Value::Null);
394    let serialized = serde_json::to_string(value).unwrap_or_default();
395    out.write(&serialized)?;
396    Ok(())
397}
398
399fn helper_secret(
400    h: &Helper,
401    _: &Handlebars,
402    ctx: &Context,
403    _: &mut RenderContext,
404    out: &mut dyn Output,
405) -> HelperResult {
406    let key = h
407        .param(0)
408        .and_then(|param| param.value().as_str())
409        .ok_or_else(|| render_error("secret helper requires a key"))?;
410
411    let root = ctx.data();
412    let host_available = root
413        .get("__secrets_meta")
414        .and_then(Value::as_object)
415        .and_then(|meta| meta.get("host_available"))
416        .and_then(Value::as_bool)
417        .unwrap_or(false);
418
419    if !host_available {
420        return Err(render_error("secret_host_unavailable"));
421    }
422
423    if let Some(Value::Object(secrets)) = root.get("secrets")
424        && let Some(value) = secrets.get(key)
425    {
426        out.write(&value_to_string(value))?;
427        return Ok(());
428    }
429
430    if let Some(denied) = root
431        .get("__secrets_meta")
432        .and_then(Value::as_object)
433        .and_then(|meta| meta.get("denied"))
434        .and_then(Value::as_object)
435        && let Some(Value::String(code)) = denied.get(key)
436    {
437        return Err(render_error(code.clone()));
438    }
439
440    Err(render_error("secret_access_denied"))
441}
442
443fn to_pointer(path: &str) -> String {
444    let cleaned = path.replace('.', "/");
445    if cleaned.starts_with('/') {
446        cleaned
447    } else {
448        format!("/{}", cleaned)
449    }
450}
451
452fn is_truthy(value: &Value) -> bool {
453    match value {
454        Value::Null => false,
455        Value::Bool(flag) => *flag,
456        Value::String(text) => !text.is_empty(),
457        Value::Number(num) => num.as_f64().is_some_and(|n| n != 0.0),
458        Value::Array(arr) => !arr.is_empty(),
459        Value::Object(map) => !map.is_empty(),
460    }
461}
462
463fn value_to_string(value: &Value) -> String {
464    match value {
465        Value::String(text) => text.clone(),
466        Value::Bool(flag) => flag.to_string(),
467        Value::Number(num) => num.to_string(),
468        Value::Null => String::new(),
469        other => serde_json::to_string(other).unwrap_or_default(),
470    }
471}