1use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6use std::sync::RwLock;
7
8use rainier_support::{Error, Result};
9use serde::Serialize;
10use serde_json::{Map, Value};
11
12use crate::template::{parse, CompareOp, Condition, Expr, Literal, Node, Template};
13
14#[derive(Debug, Clone, PartialEq)]
19pub struct View {
20 name: String,
21 data: Value,
22}
23
24impl View {
25 pub fn new(name: impl Into<String>) -> Self {
27 Self { name: name.into(), data: Value::Object(Map::new()) }
28 }
29
30 pub fn with(name: impl Into<String>, data: impl Serialize) -> Result<Self> {
32 let data = serde_json::to_value(data)?;
33 if !data.is_object() {
34 return Err(Error::internal(
35 "view data must be an object — a template reads values by name",
36 ));
37 }
38 Ok(Self { name: name.into(), data })
39 }
40
41 pub fn add(mut self, key: impl Into<String>, value: impl Serialize) -> Result<Self> {
43 let value = serde_json::to_value(value)?;
44 self.data.as_object_mut().expect("view data is always an object").insert(key.into(), value);
45 Ok(self)
46 }
47
48 pub fn name(&self) -> &str {
50 &self.name
51 }
52
53 pub fn data(&self) -> &Value {
55 &self.data
56 }
57}
58
59pub trait ViewEngine: Send + Sync + 'static {
61 fn render(&self, name: &str, data: &Value) -> Result<String>;
63
64 fn render_view(&self, view: &View) -> Result<String> {
66 self.render(view.name(), view.data())
67 }
68
69 fn exists(&self, name: &str) -> bool;
71}
72
73pub struct BladeEngine {
78 root: PathBuf,
79 extension: String,
80 cache: RwLock<HashMap<String, Template>>,
82 caching: bool,
85}
86
87impl BladeEngine {
88 pub fn new(root: impl Into<PathBuf>) -> Self {
90 Self {
91 root: root.into(),
92 extension: "blade.html".to_string(),
93 cache: RwLock::new(HashMap::new()),
94 caching: true,
95 }
96 }
97
98 pub fn without_cache(mut self) -> Self {
100 self.caching = false;
101 self
102 }
103
104 pub fn with_extension(mut self, extension: impl Into<String>) -> Self {
106 self.extension = extension.into();
107 self
108 }
109
110 pub fn root(&self) -> &Path {
112 &self.root
113 }
114
115 pub fn path_for(&self, name: &str) -> PathBuf {
125 let segments: Vec<String> = name
126 .split('.')
127 .map(|segment| segment.trim().replace(['/', '\\'], ""))
128 .filter(|segment| !segment.is_empty() && segment != "." && segment != "..")
129 .collect();
130
131 let Some((file, directories)) = segments.split_last() else {
132 return self.root.join(format!("_invalid_.{}", self.extension));
135 };
136
137 let mut path = self.root.clone();
138 for directory in directories {
139 path.push(directory);
140 }
141 path.push(format!("{file}.{}", self.extension));
142 path
143 }
144
145 pub fn flush(&self) {
147 self.cache.write().expect("view cache poisoned").clear();
148 }
149
150 fn load(&self, name: &str) -> Result<Template> {
151 if self.caching {
152 if let Some(cached) = self.cache.read().expect("view cache poisoned").get(name) {
153 return Ok(cached.clone());
154 }
155 }
156
157 let path = self.path_for(name);
158 let source = std::fs::read_to_string(&path).map_err(|e| {
159 Error::internal(format!("view `{name}` not found at {}: {e}", path.display()))
160 })?;
161
162 let template = parse(&source)
163 .map_err(|e| Error::internal(format!("view `{name}` failed to parse: {e}")))?;
164
165 if self.caching {
166 self.cache
167 .write()
168 .expect("view cache poisoned")
169 .insert(name.to_string(), template.clone());
170 }
171 Ok(template)
172 }
173}
174
175impl ViewEngine for BladeEngine {
176 fn render(&self, name: &str, data: &Value) -> Result<String> {
177 let mut renderer = Renderer { engine: self, depth: 0 };
178 renderer.render_named(name, data)
179 }
180
181 fn exists(&self, name: &str) -> bool {
182 self.path_for(name).is_file()
183 }
184}
185
186impl std::fmt::Debug for BladeEngine {
187 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188 f.debug_struct("BladeEngine")
189 .field("root", &self.root)
190 .field("caching", &self.caching)
191 .finish()
192 }
193}
194
195const MAX_DEPTH: usize = 32;
197
198struct Renderer<'a> {
199 engine: &'a BladeEngine,
200 depth: usize,
201}
202
203impl Renderer<'_> {
204 fn render_named(&mut self, name: &str, data: &Value) -> Result<String> {
205 self.depth += 1;
206 if self.depth > MAX_DEPTH {
207 return Err(Error::internal(format!(
208 "view `{name}` nests more than {MAX_DEPTH} levels deep — this is almost \
209 certainly a template including itself"
210 )));
211 }
212
213 let template = self.engine.load(name)?;
214
215 if let Some(layout) = &template.extends {
219 let layout = unquote(layout);
220 let mut sections = HashMap::new();
221 self.collect_sections(&template.nodes, data, &mut sections)?;
222
223 let parent = self.engine.load(&layout)?;
224 let mut out = String::new();
225 self.render_nodes(&parent.nodes, data, §ions, &mut out)?;
226 self.depth -= 1;
227 return Ok(out);
228 }
229
230 let mut out = String::new();
231 self.render_nodes(&template.nodes, data, &HashMap::new(), &mut out)?;
232 self.depth -= 1;
233 Ok(out)
234 }
235
236 fn collect_sections(
237 &mut self,
238 nodes: &[Node],
239 data: &Value,
240 sections: &mut HashMap<String, String>,
241 ) -> Result<()> {
242 for node in nodes {
243 if let Node::Section { name, body } = node {
244 let mut rendered = String::new();
245 self.render_nodes(body, data, &HashMap::new(), &mut rendered)?;
246 sections.insert(name.clone(), rendered);
247 }
248 }
249 Ok(())
250 }
251
252 fn render_nodes(
253 &mut self,
254 nodes: &[Node],
255 data: &Value,
256 sections: &HashMap<String, String>,
257 out: &mut String,
258 ) -> Result<()> {
259 for node in nodes {
260 match node {
261 Node::Text(text) => out.push_str(text),
262
263 Node::Echo { expr, raw } => {
264 let value = lookup(data, expr);
265 let rendered = stringify(value);
266 if *raw {
267 out.push_str(&rendered);
268 } else {
269 out.push_str(&escape_html(&rendered));
270 }
271 }
272
273 Node::If { branches, otherwise } => {
274 let mut taken = false;
275 for (condition, body) in branches {
276 if evaluate(data, condition) {
277 self.render_nodes(body, data, sections, out)?;
278 taken = true;
279 break;
280 }
281 }
282 if !taken {
283 if let Some(body) = otherwise {
284 self.render_nodes(body, data, sections, out)?;
285 }
286 }
287 }
288
289 Node::Foreach { collection, key, value, body } => {
290 let items = lookup(data, collection);
291 let pairs: Vec<(Value, Value)> = match items {
292 Some(Value::Array(items)) => items
293 .iter()
294 .enumerate()
295 .map(|(index, item)| (Value::from(index), item.clone()))
296 .collect(),
297 Some(Value::Object(map)) => {
298 map.iter().map(|(k, v)| (Value::String(k.clone()), v.clone())).collect()
299 }
300 _ => Vec::new(),
304 };
305
306 for (index, item) in pairs {
307 let mut scope = data.as_object().cloned().unwrap_or_default();
310 scope.insert(value.clone(), item);
311 if let Some(key) = key {
312 scope.insert(key.clone(), index);
313 }
314 self.render_nodes(body, &Value::Object(scope), sections, out)?;
315 }
316 }
317
318 Node::Include(name) => {
319 out.push_str(&self.render_named(name, data)?);
320 }
321
322 Node::Yield(name) => {
323 if let Some(content) = sections.get(name) {
324 out.push_str(content);
325 }
326 }
329
330 Node::Section { body, .. } => self.render_nodes(body, data, sections, out)?,
333 }
334 }
335 Ok(())
336 }
337}
338
339fn unquote(value: &str) -> String {
340 let trimmed = value.trim();
341 trimmed
342 .strip_prefix('\'')
343 .and_then(|v| v.strip_suffix('\''))
344 .or_else(|| trimmed.strip_prefix('"').and_then(|v| v.strip_suffix('"')))
345 .unwrap_or(trimmed)
346 .to_string()
347}
348
349fn lookup<'a>(data: &'a Value, expr: &Expr) -> Option<&'a Value> {
351 let mut cursor = data;
352 for segment in &expr.path {
353 cursor = match cursor {
354 Value::Object(map) => map.get(segment)?,
355 Value::Array(items) => items.get(segment.parse::<usize>().ok()?)?,
356 _ => return None,
357 };
358 }
359 Some(cursor)
360}
361
362fn stringify(value: Option<&Value>) -> String {
365 match value {
366 None | Some(Value::Null) => String::new(),
367 Some(Value::String(text)) => text.clone(),
368 Some(Value::Bool(b)) => b.to_string(),
369 Some(Value::Number(n)) => n.to_string(),
370 Some(other) => other.to_string(),
371 }
372}
373
374pub fn escape_html(value: &str) -> String {
379 let mut out = String::with_capacity(value.len());
380 for character in value.chars() {
381 match character {
382 '&' => out.push_str("&"),
383 '<' => out.push_str("<"),
384 '>' => out.push_str(">"),
385 '"' => out.push_str("""),
386 '\'' => out.push_str("'"),
387 other => out.push(other),
388 }
389 }
390 out
391}
392
393fn truthy(value: Option<&Value>) -> bool {
395 match value {
396 None | Some(Value::Null) => false,
397 Some(Value::Bool(b)) => *b,
398 Some(Value::String(text)) => !text.is_empty(),
399 Some(Value::Number(n)) => n.as_f64().is_some_and(|n| n != 0.0),
400 Some(Value::Array(items)) => !items.is_empty(),
401 Some(Value::Object(map)) => !map.is_empty(),
402 }
403}
404
405fn evaluate(data: &Value, condition: &Condition) -> bool {
406 match condition {
407 Condition::Truthy(expr) => truthy(lookup(data, expr)),
408 Condition::Falsy(expr) => !truthy(lookup(data, expr)),
409 Condition::Compare { left, op, right } => compare(lookup(data, left), *op, right),
410 }
411}
412
413fn compare(left: Option<&Value>, op: CompareOp, right: &Literal) -> bool {
414 let ordering = match (left, right) {
417 (Some(Value::Number(a)), Literal::Number(b)) => a.as_f64().and_then(|a| a.partial_cmp(b)),
418 (Some(Value::String(a)), Literal::Number(b)) => {
419 a.parse::<f64>().ok().and_then(|a| a.partial_cmp(b))
420 }
421 _ => None,
422 };
423
424 let equal = match (left, right) {
425 (None | Some(Value::Null), Literal::Null) => true,
426 (Some(Value::String(a)), Literal::Text(b)) => a == b,
427 (Some(Value::Bool(a)), Literal::Bool(b)) => a == b,
428 (Some(Value::Number(a)), Literal::Number(b)) => {
429 a.as_f64().is_some_and(|a| (a - b).abs() < f64::EPSILON)
430 }
431 (Some(Value::String(a)), Literal::Number(b)) => {
434 a.parse::<f64>().is_ok_and(|a| (a - b).abs() < f64::EPSILON)
435 }
436 (Some(Value::Number(a)), Literal::Text(b)) => a.to_string() == *b,
437 _ => false,
438 };
439
440 match op {
441 CompareOp::Eq => equal,
442 CompareOp::Ne => !equal,
443 CompareOp::Gt => ordering == Some(std::cmp::Ordering::Greater),
444 CompareOp::Gte => {
445 matches!(ordering, Some(std::cmp::Ordering::Greater) | Some(std::cmp::Ordering::Equal))
446 }
447 CompareOp::Lt => ordering == Some(std::cmp::Ordering::Less),
448 CompareOp::Lte => {
449 matches!(ordering, Some(std::cmp::Ordering::Less) | Some(std::cmp::Ordering::Equal))
450 }
451 }
452}
453
454#[derive(Debug, Default)]
457pub struct MemoryEngine {
458 templates: RwLock<HashMap<String, String>>,
459}
460
461impl MemoryEngine {
462 pub fn new() -> Self {
464 Self::default()
465 }
466
467 pub fn with(self, name: impl Into<String>, source: impl Into<String>) -> Self {
469 self.templates.write().expect("template lock poisoned").insert(name.into(), source.into());
470 self
471 }
472}
473
474impl ViewEngine for MemoryEngine {
475 fn render(&self, name: &str, data: &Value) -> Result<String> {
476 let source = self
477 .templates
478 .read()
479 .expect("template lock poisoned")
480 .get(name)
481 .cloned()
482 .ok_or_else(|| Error::internal(format!("view `{name}` is not registered")))?;
483
484 let template = parse(&source)?;
485 let mut out = String::new();
488 let engine = BladeEngine::new(".");
489 let mut renderer = Renderer { engine: &engine, depth: 0 };
490 renderer.render_nodes(&template.nodes, data, &HashMap::new(), &mut out)?;
491 Ok(out)
492 }
493
494 fn exists(&self, name: &str) -> bool {
495 self.templates.read().expect("template lock poisoned").contains_key(name)
496 }
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502 use serde_json::json;
503
504 fn render(source: &str, data: Value) -> String {
505 MemoryEngine::new().with("t", source).render("t", &data).expect("should render")
506 }
507
508 #[test]
509 fn interpolates_values() {
510 assert_eq!(render("Hi {{ name }}!", json!({ "name": "Ada" })), "Hi Ada!");
511 assert_eq!(render("{{ n }}", json!({ "n": 42 })), "42");
512 assert_eq!(render("{{ b }}", json!({ "b": true })), "true");
513 }
514
515 #[test]
516 fn reads_dotted_paths_and_array_indices() {
517 let data = json!({ "user": { "name": "Ada" }, "tags": ["a", "b"] });
518 assert_eq!(render("{{ user.name }}", data.clone()), "Ada");
519 assert_eq!(render("{{ tags.1 }}", data), "b");
520 }
521
522 #[test]
523 fn a_missing_value_renders_empty_rather_than_failing() {
524 assert_eq!(render("[{{ nope }}]", json!({})), "[]");
526 assert_eq!(render("[{{ a.b.c }}]", json!({ "a": 1 })), "[]");
527 assert_eq!(render("[{{ n }}]", json!({ "n": null })), "[]");
528 }
529
530 #[test]
531 fn interpolation_escapes_html_by_default() {
532 let data = json!({ "body": "<script>alert('xss')</script>" });
533 let rendered = render("{{ body }}", data);
534
535 assert!(!rendered.contains("<script>"), "{rendered}");
536 assert_eq!(rendered, "<script>alert('xss')</script>");
537 }
538
539 #[test]
540 fn escaping_covers_attribute_breakouts() {
541 assert_eq!(escape_html(r#"" onload="x"#), "" onload="x");
542 assert_eq!(escape_html("a & b"), "a & b");
543 }
544
545 #[test]
546 fn raw_interpolation_is_opt_in() {
547 let data = json!({ "body": "<b>bold</b>" });
548 assert_eq!(render("{!! body !!}", data), "<b>bold</b>");
549 }
550
551 #[test]
552 fn conditionals_pick_a_branch() {
553 let template =
554 "@if(role == \"admin\")admin@elseif(role == \"editor\")editor@else guest@endif";
555
556 assert_eq!(render(template, json!({ "role": "admin" })), "admin");
557 assert_eq!(render(template, json!({ "role": "editor" })), "editor");
558 assert_eq!(render(template, json!({ "role": "other" })), " guest");
559 assert_eq!(render(template, json!({})), " guest");
560 }
561
562 #[test]
563 fn truthiness_follows_the_obvious_rules() {
564 let template = "@if(v)yes@else no@endif";
565
566 for truthy in [json!(true), json!(1), json!("x"), json!(["a"]), json!({ "a": 1 })] {
567 assert_eq!(render(template, json!({ "v": truthy })), "yes");
568 }
569 for falsy in [json!(false), json!(0), json!(""), json!([]), json!({}), json!(null)] {
570 assert_eq!(render(template, json!({ "v": falsy })), " no");
571 }
572 assert_eq!(render(template, json!({})), " no", "an absent key is falsy");
573 }
574
575 #[test]
576 fn negation_inverts() {
577 assert_eq!(render("@if(!v)empty@endif", json!({ "v": false })), "empty");
578 assert_eq!(render("@if(!v)empty@endif", json!({ "v": true })), "");
579 }
580
581 #[test]
582 fn numeric_comparisons() {
583 assert_eq!(render("@if(n > 5)big@endif", json!({ "n": 10 })), "big");
584 assert_eq!(render("@if(n > 5)big@endif", json!({ "n": 3 })), "");
585 assert_eq!(render("@if(n >= 5)ok@endif", json!({ "n": 5 })), "ok");
586 assert_eq!(render("@if(n <= 5)ok@endif", json!({ "n": 5 })), "ok");
587 assert_eq!(render("@if(n < 5)small@endif", json!({ "n": 1 })), "small");
588 }
589
590 #[test]
591 fn a_numeric_string_compares_as_a_number() {
592 assert_eq!(render("@if(n > 5)big@endif", json!({ "n": "10" })), "big");
594 assert_eq!(render("@if(n == 10)ten@endif", json!({ "n": "10" })), "ten");
595 }
596
597 #[test]
598 fn ordering_a_non_number_is_false_rather_than_arbitrary() {
599 assert_eq!(render("@if(s > 5)?@endif", json!({ "s": "abc" })), "");
600 assert_eq!(render("@if(s < 5)?@endif", json!({ "s": "abc" })), "");
601 }
602
603 #[test]
604 fn loops_over_an_array() {
605 let data = json!({ "posts": [{ "title": "A" }, { "title": "B" }] });
606 assert_eq!(render("@foreach(posts as post)[{{ post.title }}]@endforeach", data), "[A][B]");
607 }
608
609 #[test]
610 fn loops_expose_the_index_or_key() {
611 let list = json!({ "items": ["a", "b"] });
612 assert_eq!(
613 render("@foreach(items as i => v){{ i }}={{ v }};@endforeach", list),
614 "0=a;1=b;"
615 );
616
617 let map = json!({ "meta": { "x": "1" } });
618 assert_eq!(render("@foreach(meta as k => v){{ k }}:{{ v }}@endforeach", map), "x:1");
619 }
620
621 #[test]
622 fn looping_over_nothing_renders_nothing() {
623 assert_eq!(render("[@foreach(items as i)x@endforeach]", json!({})), "[]");
624 assert_eq!(render("[@foreach(items as i)x@endforeach]", json!({ "items": [] })), "[]");
625 assert_eq!(
626 render("[@foreach(items as i)x@endforeach]", json!({ "items": "not a list" })),
627 "[]"
628 );
629 }
630
631 #[test]
632 fn the_outer_scope_is_visible_inside_a_loop() {
633 let data = json!({ "prefix": "-", "items": ["a"] });
634 assert_eq!(render("@foreach(items as i){{ prefix }}{{ i }}@endforeach", data), "-a");
635 }
636
637 #[test]
638 fn loops_and_conditionals_nest() {
639 let data =
640 json!({ "posts": [{ "published": true, "t": "A" }, { "published": false, "t": "B" }] });
641 assert_eq!(
642 render("@foreach(posts as p)@if(p.published){{ p.t }}@endif@endforeach", data),
643 "A"
644 );
645 }
646
647 fn temp_root(name: &str) -> PathBuf {
650 let root = std::env::temp_dir().join(format!("rainier-view-{name}"));
651 let _ = std::fs::remove_dir_all(&root);
652 std::fs::create_dir_all(&root).expect("temp dir");
653 root
654 }
655
656 fn write(root: &Path, name: &str, source: &str) {
657 let path = root.join(format!("{name}.blade.html"));
658 if let Some(parent) = path.parent() {
659 std::fs::create_dir_all(parent).expect("parent dir");
660 }
661 std::fs::write(path, source).expect("write template");
662 }
663
664 #[test]
665 fn renders_a_template_from_disk() {
666 let root = temp_root("basic");
667 write(&root, "greeting", "Hi {{ name }}");
668
669 let engine = BladeEngine::new(&root);
670 assert!(engine.exists("greeting"));
671 assert_eq!(engine.render("greeting", &json!({ "name": "Ada" })).unwrap(), "Hi Ada");
672 }
673
674 #[test]
675 fn a_missing_template_names_itself_and_its_path() {
676 let engine = BladeEngine::new(temp_root("missing"));
677 let err = engine.render("nope", &json!({})).err().expect("should fail");
678
679 assert!(err.message().contains("`nope`"), "{}", err.message());
680 assert!(err.message().contains("nope.blade.html"), "{}", err.message());
681 assert!(!engine.exists("nope"));
682 }
683
684 #[test]
685 fn a_dotted_name_maps_to_a_subdirectory() {
686 let root = temp_root("nested");
687 std::fs::create_dir_all(root.join("posts")).unwrap();
688 write(&root, "posts/show", "post");
689
690 let engine = BladeEngine::new(&root);
691 assert_eq!(engine.render("posts.show", &json!({})).unwrap(), "post");
692 }
693
694 #[test]
695 fn a_view_name_cannot_escape_the_template_root() {
696 let root = temp_root("traversal");
699 let engine = BladeEngine::new(&root);
700
701 for hostile in ["..secrets", "../../etc/passwd", ".", "..", "a/../../b", ""] {
702 let path = engine.path_for(hostile);
703 assert!(path.starts_with(&root), "`{hostile}` escaped to {path:?}");
704 assert!(!path.to_string_lossy().contains(".."), "`{hostile}` produced {path:?}");
705 }
706 }
707
708 #[test]
709 fn a_normal_name_still_maps_the_obvious_way() {
710 let engine = BladeEngine::new("views");
711 assert_eq!(
712 engine.path_for("posts.show"),
713 PathBuf::from("views").join("posts").join("show.blade.html")
714 );
715 }
716
717 #[test]
718 fn includes_are_rendered_with_the_same_data() {
719 let root = temp_root("include");
720 write(&root, "page", "<main>@include('parts.nav')</main>");
721 write(&root, "parts/nav", "<nav>{{ title }}</nav>");
722
723 let engine = BladeEngine::new(&root);
724 assert_eq!(
725 engine.render("page", &json!({ "title": "Home" })).unwrap(),
726 "<main><nav>Home</nav></main>"
727 );
728 }
729
730 #[test]
731 fn a_layout_yields_the_childs_sections() {
732 let root = temp_root("layout");
733 write(
734 &root,
735 "layout",
736 "<html><title>@yield('title')</title><body>@yield('body')</body></html>",
737 );
738 write(
739 &root,
740 "page",
741 "@extends('layout')@section('title')Hi@endsection@section('body')<p>{{ name }}</p>@endsection",
742 );
743
744 let engine = BladeEngine::new(&root);
745 assert_eq!(
746 engine.render("page", &json!({ "name": "Ada" })).unwrap(),
747 "<html><title>Hi</title><body><p>Ada</p></body></html>"
748 );
749 }
750
751 #[test]
752 fn a_yield_with_no_section_renders_nothing() {
753 let root = temp_root("optional-yield");
754 write(&root, "layout", "[@yield('sidebar')]");
755 write(&root, "page", "@extends('layout')");
756
757 let engine = BladeEngine::new(&root);
758 assert_eq!(engine.render("page", &json!({})).unwrap(), "[]");
759 }
760
761 #[test]
762 fn a_self_including_template_is_caught_rather_than_overflowing_the_stack() {
763 let root = temp_root("cycle");
764 write(&root, "loop", "@include('loop')");
765
766 let engine = BladeEngine::new(&root);
767 let err = engine.render("loop", &json!({})).err().expect("should fail");
768 assert!(err.message().contains("nests more than"), "{}", err.message());
769 }
770
771 #[test]
772 fn caching_can_be_turned_off_for_development() {
773 let root = temp_root("cache");
774 write(&root, "page", "one");
775
776 let cached = BladeEngine::new(&root);
777 assert_eq!(cached.render("page", &json!({})).unwrap(), "one");
778 write(&root, "page", "two");
779 assert_eq!(cached.render("page", &json!({})).unwrap(), "one", "still cached");
780
781 cached.flush();
782 assert_eq!(cached.render("page", &json!({})).unwrap(), "two");
783
784 let live = BladeEngine::new(&root).without_cache();
785 write(&root, "page", "three");
786 assert_eq!(live.render("page", &json!({})).unwrap(), "three");
787 }
788
789 #[test]
790 fn a_view_carries_its_name_and_data() {
791 let view = View::new("posts.show").add("id", 7).unwrap();
792 assert_eq!(view.name(), "posts.show");
793 assert_eq!(view.data()["id"], 7);
794 }
795
796 #[test]
797 fn view_data_must_be_an_object() {
798 assert!(View::with("x", serde_json::json!({ "a": 1 })).is_ok());
799 let err = View::with("x", "a bare string").err().expect("should fail");
800 assert!(err.message().contains("must be an object"), "{}", err.message());
801 }
802
803 #[test]
804 fn render_view_goes_through_the_engine() {
805 let engine = MemoryEngine::new().with("t", "Hi {{ name }}");
806 let view = View::new("t").add("name", "Ada").unwrap();
807 assert_eq!(engine.render_view(&view).unwrap(), "Hi Ada");
808 }
809}