sqlpage/
template_helpers.rs

1use std::borrow::Cow;
2
3use crate::{app_config::AppConfig, utils::static_filename};
4use anyhow::Context as _;
5use handlebars::{
6    handlebars_helper, Context, Handlebars, HelperDef, JsonTruthy, PathAndJson, RenderError,
7    RenderErrorReason, Renderable, ScopedJson,
8};
9use serde_json::Value as JsonValue;
10
11/// Simple static json helper
12type H0 = fn() -> JsonValue;
13/// Simple json to json helper
14type H = fn(&JsonValue) -> JsonValue;
15/// Simple json to json helper with error handling
16type EH = fn(&JsonValue) -> anyhow::Result<JsonValue>;
17/// Helper that takes two arguments
18type HH = fn(&JsonValue, &JsonValue) -> JsonValue;
19/// Helper that takes three arguments
20#[allow(clippy::upper_case_acronyms)]
21type HHH = fn(&JsonValue, &JsonValue, &JsonValue) -> JsonValue;
22
23pub fn register_all_helpers(h: &mut Handlebars<'_>, config: &AppConfig) {
24    let site_prefix = config.site_prefix.clone();
25
26    register_helper(h, "all", HelperCheckTruthy(false));
27    register_helper(h, "any", HelperCheckTruthy(true));
28
29    register_helper(h, "stringify", stringify_helper as H);
30    register_helper(h, "parse_json", parse_json_helper as EH);
31    register_helper(h, "default", default_helper as HH);
32    register_helper(h, "entries", entries_helper as H);
33    register_helper(h, "replace", replace_helper as HHH);
34    // delay helper: store a piece of information in memory that can be output later with flush_delayed
35    h.register_helper("delay", Box::new(delay_helper));
36    h.register_helper("flush_delayed", Box::new(flush_delayed_helper));
37    register_helper(h, "plus", plus_helper as HH);
38    register_helper(h, "minus", minus_helper as HH);
39    h.register_helper("sum", Box::new(sum_helper));
40    register_helper(h, "starts_with", starts_with_helper as HH);
41
42    // to_array: convert a value to a single-element array. If the value is already an array, return it as-is.
43    register_helper(h, "to_array", to_array_helper as H);
44
45    // array_contains: check if an array contains an element. If the first argument is not an array, it is compared to the second argument.
46    handlebars_helper!(array_contains: |array: Json, element: Json| match array {
47        JsonValue::Array(arr) => arr.contains(element),
48        other => other == element
49    });
50    h.register_helper("array_contains", Box::new(array_contains));
51
52    // array_contains_case_insensitive: check if an array contains an element case-insensitively. If the first argument is not an array, it is compared to the second argument case-insensitively.
53    handlebars_helper!(array_contains_case_insensitive: |array: Json, element: Json| {
54        match array {
55            JsonValue::Array(arr) => arr.iter().any(|v| json_eq_case_insensitive(v, element)),
56            other => json_eq_case_insensitive(other, element),
57        }
58    });
59    h.register_helper(
60        "array_contains_case_insensitive",
61        Box::new(array_contains_case_insensitive),
62    );
63
64    // static_path helper: generate a path to a static file. Replaces sqpage.js by sqlpage.<hash>.js
65    register_helper(h, "static_path", StaticPathHelper(site_prefix.clone()));
66    register_helper(h, "app_config", AppConfigHelper(config.clone()));
67
68    // icon helper: generate an image with the specified icon
69    h.register_helper("icon_img", Box::new(IconImgHelper(site_prefix)));
70    register_helper(h, "markdown", MarkdownHelper::new(config));
71    register_helper(h, "buildinfo", buildinfo_helper as EH);
72    register_helper(h, "typeof", typeof_helper as H);
73    register_helper(h, "rfc2822_date", rfc2822_date_helper as EH);
74    register_helper(h, "url_encode", url_encode_helper as H);
75    register_helper(h, "csv_escape", csv_escape_helper as HH);
76}
77
78fn json_eq_case_insensitive(a: &JsonValue, b: &JsonValue) -> bool {
79    match (a, b) {
80        (JsonValue::String(a), JsonValue::String(b)) => a.eq_ignore_ascii_case(b),
81        _ => a == b,
82    }
83}
84
85fn stringify_helper(v: &JsonValue) -> JsonValue {
86    v.to_string().into()
87}
88
89fn parse_json_helper(v: &JsonValue) -> Result<JsonValue, anyhow::Error> {
90    Ok(match v {
91        serde_json::value::Value::String(s) => serde_json::from_str(s)?,
92        other => other.clone(),
93    })
94}
95
96fn default_helper(v: &JsonValue, default: &JsonValue) -> JsonValue {
97    if v.is_null() {
98        default.clone()
99    } else {
100        v.clone()
101    }
102}
103
104fn plus_helper(a: &JsonValue, b: &JsonValue) -> JsonValue {
105    if let (Some(a), Some(b)) = (a.as_i64(), b.as_i64()) {
106        (a + b).into()
107    } else if let (Some(a), Some(b)) = (a.as_f64(), b.as_f64()) {
108        (a + b).into()
109    } else {
110        JsonValue::Null
111    }
112}
113
114fn minus_helper(a: &JsonValue, b: &JsonValue) -> JsonValue {
115    if let (Some(a), Some(b)) = (a.as_i64(), b.as_i64()) {
116        (a - b).into()
117    } else if let (Some(a), Some(b)) = (a.as_f64(), b.as_f64()) {
118        (a - b).into()
119    } else {
120        JsonValue::Null
121    }
122}
123
124fn starts_with_helper(a: &JsonValue, b: &JsonValue) -> JsonValue {
125    if let (Some(a), Some(b)) = (a.as_str(), b.as_str()) {
126        a.starts_with(b)
127    } else if let (Some(arr1), Some(arr2)) = (a.as_array(), b.as_array()) {
128        arr1.starts_with(arr2)
129    } else {
130        false
131    }
132    .into()
133}
134fn entries_helper(v: &JsonValue) -> JsonValue {
135    match v {
136        serde_json::value::Value::Object(map) => map
137            .into_iter()
138            .map(|(k, v)| serde_json::json!({"key": k, "value": v}))
139            .collect(),
140        serde_json::value::Value::Array(values) => values
141            .iter()
142            .enumerate()
143            .map(|(k, v)| serde_json::json!({"key": k, "value": v}))
144            .collect(),
145        _ => vec![],
146    }
147    .into()
148}
149
150fn to_array_helper(v: &JsonValue) -> JsonValue {
151    match v {
152        JsonValue::Array(arr) => arr.clone(),
153        JsonValue::Null => vec![],
154        JsonValue::String(s) if s.starts_with('[') => {
155            if let Ok(JsonValue::Array(r)) = serde_json::from_str(s) {
156                r
157            } else {
158                vec![JsonValue::String(s.clone())]
159            }
160        }
161        other => vec![other.clone()],
162    }
163    .into()
164}
165
166/// Generate the full path to a builtin sqlpage asset. Struct Param is the site prefix
167struct StaticPathHelper(String);
168
169impl CanHelp for StaticPathHelper {
170    fn call(&self, args: &[PathAndJson]) -> Result<JsonValue, String> {
171        let static_file = match args {
172            [v] => v.value(),
173            _ => return Err("expected one argument".to_string()),
174        };
175        let name = static_file
176            .as_str()
177            .ok_or_else(|| format!("static_path: not a string: {static_file}"))?;
178        let path = match name {
179            "sqlpage.js" => static_filename!("sqlpage.js"),
180            "sqlpage.css" => static_filename!("sqlpage.css"),
181            "apexcharts.js" => static_filename!("apexcharts.js"),
182            "tomselect.js" => static_filename!("tomselect.js"),
183            "favicon.svg" => static_filename!("favicon.svg"),
184            other => return Err(format!("unknown static file: {other:?}")),
185        };
186        Ok(format!("{}{}", self.0, path).into())
187    }
188}
189
190/// Generate the full path to a builtin sqlpage asset. Struct Param is the site prefix
191struct AppConfigHelper(AppConfig);
192
193impl CanHelp for AppConfigHelper {
194    fn call(&self, args: &[PathAndJson]) -> Result<JsonValue, String> {
195        let static_file = match args {
196            [v] => v.value(),
197            _ => return Err("expected one argument".to_string()),
198        };
199        let name = static_file
200            .as_str()
201            .ok_or_else(|| format!("app_config: not a string: {static_file}"))?;
202        match name {
203            "max_uploaded_file_size" => Ok(JsonValue::Number(self.0.max_uploaded_file_size.into())),
204            "environment" => serde_json::to_value(self.0.environment).map_err(|e| e.to_string()),
205            "site_prefix" => Ok(self.0.site_prefix.clone().into()),
206            other => Err(format!("unknown app config property: {other:?}")),
207        }
208    }
209}
210
211/// Generate an image with the specified icon. Struct Param is the site prefix
212struct IconImgHelper(String);
213impl HelperDef for IconImgHelper {
214    fn call<'reg: 'rc, 'rc>(
215        &self,
216        helper: &handlebars::Helper<'rc>,
217        _r: &'reg Handlebars<'reg>,
218        _ctx: &'rc Context,
219        _rc: &mut handlebars::RenderContext<'reg, 'rc>,
220        writer: &mut dyn handlebars::Output,
221    ) -> handlebars::HelperResult {
222        let null = handlebars::JsonValue::Null;
223        let params = [0, 1].map(|i| helper.params().get(i).map_or(&null, PathAndJson::value));
224        let name = match params[0] {
225            JsonValue::String(s) => s,
226            other => {
227                log::debug!("icon_img: {other:?} is not an icon name, not rendering anything");
228                return Ok(());
229            }
230        };
231        let size = params[1].as_u64().unwrap_or(24);
232        write!(
233            writer,
234            "<svg width={size} height={size}><use href=\"{}{}#tabler-{name}\" /></svg>",
235            self.0,
236            static_filename!("tabler-icons.svg")
237        )?;
238        Ok(())
239    }
240}
241
242fn typeof_helper(v: &JsonValue) -> JsonValue {
243    match v {
244        JsonValue::Null => "null",
245        JsonValue::Bool(_) => "boolean",
246        JsonValue::Number(_) => "number",
247        JsonValue::String(_) => "string",
248        JsonValue::Array(_) => "array",
249        JsonValue::Object(_) => "object",
250    }
251    .into()
252}
253
254pub trait MarkdownConfig {
255    fn allow_dangerous_html(&self) -> bool;
256    fn allow_dangerous_protocol(&self) -> bool;
257}
258
259impl MarkdownConfig for AppConfig {
260    fn allow_dangerous_html(&self) -> bool {
261        self.markdown_allow_dangerous_html
262    }
263
264    fn allow_dangerous_protocol(&self) -> bool {
265        self.markdown_allow_dangerous_protocol
266    }
267}
268
269/// Helper to render markdown with configurable options
270#[derive(Default)]
271struct MarkdownHelper {
272    allow_dangerous_html: bool,
273    allow_dangerous_protocol: bool,
274}
275
276impl MarkdownHelper {
277    fn new(config: &impl MarkdownConfig) -> Self {
278        Self {
279            allow_dangerous_html: config.allow_dangerous_html(),
280            allow_dangerous_protocol: config.allow_dangerous_protocol(),
281        }
282    }
283
284    fn get_preset_options(&self, preset_name: &str) -> Result<markdown::Options, String> {
285        let mut options = markdown::Options::gfm();
286        options.compile.allow_dangerous_html = self.allow_dangerous_html;
287        options.compile.allow_dangerous_protocol = self.allow_dangerous_protocol;
288        options.compile.allow_any_img_src = true;
289
290        match preset_name {
291            "default" => {}
292            "allow_unsafe" => {
293                options.compile.allow_dangerous_html = true;
294                options.compile.allow_dangerous_protocol = true;
295            }
296            _ => return Err(format!("unknown markdown preset: {preset_name}")),
297        }
298
299        Ok(options)
300    }
301}
302
303impl CanHelp for MarkdownHelper {
304    fn call(&self, args: &[PathAndJson]) -> Result<JsonValue, String> {
305        let (markdown_src_value, preset_name) = match args {
306            [v] => (v.value(), "default"),
307            [v, preset] => {
308                let value = v.value();
309                let preset_name_value = preset.value();
310                let preset = preset_name_value.as_str()
311                    .ok_or_else(|| format!("markdown template helper expects a string as preset name. Got: {preset_name_value}"))?;
312                (value, preset)
313            }
314            _ => return Err("markdown template helper expects one or two arguments".to_string()),
315        };
316        let markdown_src = match markdown_src_value {
317            JsonValue::String(s) => Cow::Borrowed(s),
318            JsonValue::Array(arr) => Cow::Owned(
319                arr.iter()
320                    .map(|v| v.as_str().unwrap_or_default())
321                    .collect::<Vec<_>>()
322                    .join("\n"),
323            ),
324            JsonValue::Null => Cow::Owned(String::new()),
325            other => Cow::Owned(other.to_string()),
326        };
327
328        let options = self.get_preset_options(preset_name)?;
329        markdown::to_html_with_options(&markdown_src, &options)
330            .map(JsonValue::String)
331            .map_err(|e| e.to_string())
332    }
333}
334
335fn buildinfo_helper(x: &JsonValue) -> anyhow::Result<JsonValue> {
336    match x {
337        JsonValue::String(s) if s == "CARGO_PKG_NAME" => Ok(env!("CARGO_PKG_NAME").into()),
338        JsonValue::String(s) if s == "CARGO_PKG_VERSION" => Ok(env!("CARGO_PKG_VERSION").into()),
339        other => Err(anyhow::anyhow!("unknown buildinfo key: {other:?}")),
340    }
341}
342
343// rfc2822_date: take an ISO date and convert it to an RFC 2822 date
344fn rfc2822_date_helper(v: &JsonValue) -> anyhow::Result<JsonValue> {
345    let date: chrono::DateTime<chrono::FixedOffset> = match v {
346        JsonValue::String(s) => {
347            // we accept both dates with and without time
348            chrono::DateTime::parse_from_rfc3339(s)
349                .or_else(|_| {
350                    chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
351                        .map(|d| d.and_hms_opt(0, 0, 0).unwrap().and_utc().fixed_offset())
352                })
353                .with_context(|| format!("invalid date: {s}"))?
354        }
355        JsonValue::Number(n) => {
356            chrono::DateTime::from_timestamp(n.as_i64().with_context(|| "not a timestamp")?, 0)
357                .with_context(|| "invalid timestamp")?
358                .into()
359        }
360        other => anyhow::bail!("expected a date, got {other:?}"),
361    };
362    // format: Thu, 01 Jan 1970 00:00:00 +0000
363    Ok(date.format("%a, %d %b %Y %T %z").to_string().into())
364}
365
366// Percent-encode a string
367fn url_encode_helper(v: &JsonValue) -> JsonValue {
368    let as_str = match v {
369        JsonValue::String(s) => s,
370        other => &other.to_string(),
371    };
372    percent_encoding::percent_encode(as_str.as_bytes(), percent_encoding::NON_ALPHANUMERIC)
373        .to_string()
374        .into()
375}
376
377// Percent-encode a string
378fn csv_escape_helper(v: &JsonValue, separator: &JsonValue) -> JsonValue {
379    let as_str = match v {
380        JsonValue::String(s) => s,
381        other => &other.to_string(),
382    };
383    let separator = separator.as_str().unwrap_or(",");
384    if as_str.contains(separator) || as_str.contains('"') || as_str.contains('\n') {
385        format!(r#""{}""#, as_str.replace('"', r#""""#)).into()
386    } else {
387        as_str.to_owned().into()
388    }
389}
390
391fn with_each_block<'a, 'reg, 'rc>(
392    rc: &'a mut handlebars::RenderContext<'reg, 'rc>,
393    mut action: impl FnMut(&mut handlebars::BlockContext<'rc>, bool) -> Result<(), RenderError>,
394) -> Result<(), RenderError> {
395    let mut blks = Vec::new();
396    while let Some(mut top) = rc.block_mut().map(std::mem::take) {
397        rc.pop_block();
398        action(&mut top, rc.block().is_none())?;
399        blks.push(top);
400    }
401    while let Some(blk) = blks.pop() {
402        rc.push_block(blk);
403    }
404    Ok(())
405}
406
407pub(crate) const DELAYED_CONTENTS: &str = "_delayed_contents";
408
409fn delay_helper<'reg, 'rc>(
410    h: &handlebars::Helper<'rc>,
411    r: &'reg Handlebars<'reg>,
412    ctx: &'rc Context,
413    rc: &mut handlebars::RenderContext<'reg, 'rc>,
414    _out: &mut dyn handlebars::Output,
415) -> handlebars::HelperResult {
416    let inner = h
417        .template()
418        .ok_or(RenderErrorReason::BlockContentRequired)?;
419    let mut str_out = handlebars::StringOutput::new();
420    inner.render(r, ctx, rc, &mut str_out)?;
421    let mut delayed_render = str_out.into_string()?;
422    with_each_block(rc, |block, is_last| {
423        if is_last {
424            let old_delayed_render = block
425                .get_local_var(DELAYED_CONTENTS)
426                .and_then(JsonValue::as_str)
427                .unwrap_or_default();
428            delayed_render += old_delayed_render;
429            let contents = JsonValue::String(std::mem::take(&mut delayed_render));
430            block.set_local_var(DELAYED_CONTENTS, contents);
431        }
432        Ok(())
433    })?;
434    Ok(())
435}
436
437fn flush_delayed_helper<'reg, 'rc>(
438    _h: &handlebars::Helper<'rc>,
439    _r: &'reg Handlebars<'reg>,
440    _ctx: &'rc Context,
441    rc: &mut handlebars::RenderContext<'reg, 'rc>,
442    writer: &mut dyn handlebars::Output,
443) -> handlebars::HelperResult {
444    with_each_block(rc, |block_context, _last| {
445        let delayed = block_context
446            .get_local_var(DELAYED_CONTENTS)
447            .and_then(JsonValue::as_str)
448            .filter(|s| !s.is_empty());
449        if let Some(contents) = delayed {
450            writer.write(contents)?;
451            block_context.set_local_var(DELAYED_CONTENTS, JsonValue::Null);
452        }
453        Ok(())
454    })
455}
456
457fn sum_helper<'reg, 'rc>(
458    helper: &handlebars::Helper<'rc>,
459    _r: &'reg Handlebars<'reg>,
460    _ctx: &'rc Context,
461    _rc: &mut handlebars::RenderContext<'reg, 'rc>,
462    writer: &mut dyn handlebars::Output,
463) -> handlebars::HelperResult {
464    let mut sum = 0f64;
465    for v in helper.params() {
466        sum += v
467            .value()
468            .as_f64()
469            .ok_or(RenderErrorReason::InvalidParamType("number"))?;
470    }
471    write!(writer, "{sum}")?;
472    Ok(())
473}
474
475/// Helper that returns the first argument with the given truthiness, or the last argument if none have it.
476/// Equivalent to a && b && c && ... if the truthiness is false,
477/// or a || b || c || ... if the truthiness is true.
478pub struct HelperCheckTruthy(bool);
479
480impl CanHelp for HelperCheckTruthy {
481    fn call(&self, args: &[PathAndJson]) -> Result<JsonValue, String> {
482        for arg in args {
483            if arg.value().is_truthy(false) == self.0 {
484                return Ok(arg.value().clone());
485            }
486        }
487        if let Some(last) = args.last() {
488            Ok(last.value().clone())
489        } else {
490            Err("expected at least one argument".to_string())
491        }
492    }
493}
494
495trait CanHelp: Send + Sync + 'static {
496    fn call(&self, v: &[PathAndJson]) -> Result<JsonValue, String>;
497}
498
499impl CanHelp for H0 {
500    fn call(&self, args: &[PathAndJson]) -> Result<JsonValue, String> {
501        match args {
502            [] => Ok(self()),
503            _ => Err("expected no arguments".to_string()),
504        }
505    }
506}
507
508impl CanHelp for H {
509    fn call(&self, args: &[PathAndJson]) -> Result<JsonValue, String> {
510        match args {
511            [v] => Ok(self(v.value())),
512            _ => Err("expected one argument".to_string()),
513        }
514    }
515}
516
517impl CanHelp for EH {
518    fn call(&self, args: &[PathAndJson]) -> Result<JsonValue, String> {
519        match args {
520            [v] => self(v.value()).map_err(|e| e.to_string()),
521            _ => Err("expected one argument".to_string()),
522        }
523    }
524}
525
526impl CanHelp for HH {
527    fn call(&self, args: &[PathAndJson]) -> Result<JsonValue, String> {
528        match args {
529            [a, b] => Ok(self(a.value(), b.value())),
530            _ => Err("expected two arguments".to_string()),
531        }
532    }
533}
534
535impl CanHelp for HHH {
536    fn call(&self, args: &[PathAndJson]) -> Result<JsonValue, String> {
537        match args {
538            [a, b, c] => Ok(self(a.value(), b.value(), c.value())),
539            _ => Err("expected three arguments".to_string()),
540        }
541    }
542}
543
544struct JFun<F: CanHelp> {
545    name: &'static str,
546    fun: F,
547}
548impl<F: CanHelp> handlebars::HelperDef for JFun<F> {
549    fn call_inner<'reg: 'rc, 'rc>(
550        &self,
551        helper: &handlebars::Helper<'rc>,
552        _r: &'reg Handlebars<'reg>,
553        _: &'rc Context,
554        _rc: &mut handlebars::RenderContext<'reg, 'rc>,
555    ) -> Result<handlebars::ScopedJson<'rc>, RenderError> {
556        let result = self
557            .fun
558            .call(helper.params().as_slice())
559            .map_err(|s| RenderErrorReason::Other(format!("{}: {}", self.name, s)))?;
560        Ok(ScopedJson::Derived(result))
561    }
562}
563
564fn register_helper(h: &mut Handlebars, name: &'static str, fun: impl CanHelp) {
565    h.register_helper(name, Box::new(JFun { name, fun }));
566}
567
568fn replace_helper(text: &JsonValue, original: &JsonValue, replacement: &JsonValue) -> JsonValue {
569    let text_str = match text {
570        JsonValue::String(s) => s,
571        other => &other.to_string(),
572    };
573    let original_str = match original {
574        JsonValue::String(s) => s,
575        other => &other.to_string(),
576    };
577    let replacement_str = match replacement {
578        JsonValue::String(s) => s,
579        other => &other.to_string(),
580    };
581
582    text_str.replace(original_str, replacement_str).into()
583}
584
585#[cfg(test)]
586mod tests {
587    use crate::template_helpers::{rfc2822_date_helper, CanHelp, MarkdownHelper};
588    use handlebars::{JsonValue, PathAndJson, ScopedJson};
589    use serde_json::Value;
590
591    const CONTENT_KEY: &str = "contents_md";
592
593    #[test]
594    fn test_rfc2822_date() {
595        assert_eq!(
596            rfc2822_date_helper(&JsonValue::String("1970-01-02T03:04:05+02:00".into()))
597                .unwrap()
598                .as_str()
599                .unwrap(),
600            "Fri, 02 Jan 1970 03:04:05 +0200"
601        );
602        assert_eq!(
603            rfc2822_date_helper(&JsonValue::String("1970-01-02".into()))
604                .unwrap()
605                .as_str()
606                .unwrap(),
607            "Fri, 02 Jan 1970 00:00:00 +0000"
608        );
609    }
610
611    #[test]
612    fn test_basic_gfm_markdown() {
613        let helper = MarkdownHelper::default();
614
615        let contents = Value::String("# Heading".to_string());
616        let actual = helper.call(&as_args(&contents)).unwrap();
617
618        assert_eq!(Some("<h1>Heading</h1>"), actual.as_str());
619    }
620
621    // Optionally allow potentially unsafe html blocks
622    // See https://spec.commonmark.org/0.31.2/#html-blocks
623    mod markdown_html_blocks {
624
625        use super::*;
626
627        const UNSAFE_MARKUP: &str = "<table><tr><td>";
628        const ESCAPED_UNSAFE_MARKUP: &str = "&lt;table&gt;&lt;tr&gt;&lt;td&gt;";
629        #[test]
630        fn test_html_blocks_with_various_settings() {
631            struct TestCase {
632                name: &'static str,
633                preset: Option<Value>,
634                expected_output: Result<&'static str, String>,
635            }
636
637            let helper = MarkdownHelper::default();
638            let content = contents();
639
640            let test_cases = [
641                TestCase {
642                    name: "default settings",
643                    preset: Some(Value::String("default".to_string())),
644                    expected_output: Ok(ESCAPED_UNSAFE_MARKUP),
645                },
646                TestCase {
647                    name: "allow_unsafe preset",
648                    preset: Some(Value::String("allow_unsafe".to_string())),
649                    expected_output: Ok(UNSAFE_MARKUP),
650                },
651                TestCase {
652                    name: "undefined allow_unsafe",
653                    preset: Some(Value::Null),
654                    expected_output: Err(
655                        "markdown template helper expects a string as preset name. Got: null"
656                            .to_string(),
657                    ),
658                },
659                TestCase {
660                    name: "allow_unsafe is false",
661                    preset: Some(Value::Bool(false)),
662                    expected_output: Err(
663                        "markdown template helper expects a string as preset name. Got: false"
664                            .to_string(),
665                    ),
666                },
667            ];
668
669            for case in test_cases {
670                let args = match case.preset {
671                    None => &as_args(&content)[..],
672                    Some(ref preset) => &as_args_with_unsafe(&content, preset)[..],
673                };
674
675                match helper.call(args) {
676                    Ok(actual) => assert_eq!(
677                        case.expected_output.unwrap(),
678                        actual.as_str().unwrap(),
679                        "Failed on case: {}",
680                        case.name
681                    ),
682                    Err(e) => assert_eq!(
683                        case.expected_output.unwrap_err(),
684                        e,
685                        "Failed on case: {}",
686                        case.name
687                    ),
688                }
689            }
690        }
691
692        fn as_args_with_unsafe<'a>(
693            contents: &'a Value,
694            allow_unsafe: &'a Value,
695        ) -> [PathAndJson<'a>; 2] {
696            [
697                as_helper_arg(CONTENT_KEY, contents),
698                as_helper_arg("allow_unsafe", allow_unsafe),
699            ]
700        }
701
702        fn contents() -> Value {
703            Value::String(UNSAFE_MARKUP.to_string())
704        }
705    }
706
707    fn as_args(contents: &Value) -> [PathAndJson; 1] {
708        [as_helper_arg(CONTENT_KEY, contents)]
709    }
710
711    fn as_helper_arg<'a>(path: &'a str, value: &'a Value) -> PathAndJson<'a> {
712        let json_context = as_json_context(path, value);
713        to_path_and_json(path, json_context)
714    }
715
716    fn to_path_and_json<'a>(path: &'a str, value: ScopedJson<'a>) -> PathAndJson<'a> {
717        PathAndJson::new(Some(path.to_string()), value)
718    }
719
720    fn as_json_context<'a>(path: &'a str, value: &'a Value) -> ScopedJson<'a> {
721        ScopedJson::Context(value, vec![path.to_string()])
722    }
723}