Skip to main content

topcoat_view/
view.rs

1use core::fmt;
2use std::borrow::Cow;
3
4#[cfg(feature = "http")]
5use http::{HeaderMap, StatusCode};
6use smallvec::SmallVec;
7use topcoat_core::context::Cx;
8
9use crate::{Formatter, HtmlContext, HtmlWriter};
10
11/// A self-contained piece of HTML content.
12///
13/// A view may contain multiple sibling nodes, but opened tags must be closed
14/// so the fragment can be nested safely inside a larger document.
15///
16/// ```html
17/// <!-- Valid: all tags are closed, safe to nest -->
18/// <div>Hello</div>
19/// <p>World</p>
20///
21/// <!-- Invalid: unclosed tag would corrupt the parent document -->
22/// <div>Hello
23/// ```
24#[derive(Debug, Default, Clone)]
25pub struct View {
26    part: ViewPart,
27}
28
29impl View {
30    /// Creates a view from accumulated view parts.
31    ///
32    /// This is called by generated `view!` code after collecting the nodes
33    /// and attributes for a fragment.
34    #[doc(hidden)]
35    #[inline]
36    #[must_use]
37    pub fn new(parts: ViewParts) -> Self {
38        Self { part: parts.into() }
39    }
40
41    /// Returns a `View` that renders to an empty string.
42    #[inline]
43    #[must_use]
44    pub fn empty() -> Self {
45        Self::default()
46    }
47
48    /// Creates a view from a `&'static str` without escaping it and without checking for syntax
49    /// errors.
50    #[inline]
51    #[must_use]
52    pub const fn unescaped_unchecked(body: &'static str) -> Self {
53        Self {
54            part: ViewPart::unescaped(body),
55        }
56    }
57
58    /// Renders the view into an HTML string.
59    #[cfg_attr(
60        feature = "http",
61        doc = "",
62        doc = "Status codes and headers declared in the view are discarded;",
63        doc = "[`render_response`](Self::render_response) collects them."
64    )]
65    pub fn render(&self, cx: &Cx) -> String {
66        let mut buf = String::with_capacity(self.part.size_hint());
67        let mut f = Formatter::new(&mut buf);
68        self.part.render(cx, &mut f);
69        buf
70    }
71
72    /// Renders the view into HTML together with the status code and response
73    /// headers declared in it.
74    ///
75    /// A view declares response metadata by placing an
76    /// [`http::StatusCode`](StatusCode), an [`http::HeaderMap`](HeaderMap),
77    /// or a single `(HeaderName, HeaderValue)` pair in the node position of
78    /// the `view!` macro. Competing declarations resolve by render order:
79    /// the first status code rendered wins, and the first part that mentions
80    /// a header name provides all of that name's values.
81    #[cfg(feature = "http")]
82    #[must_use]
83    pub fn render_response(&self, cx: &Cx) -> RenderedResponse {
84        let mut html = String::with_capacity(self.part.size_hint());
85        let mut f = Formatter::new(&mut html);
86        self.part.render(cx, &mut f);
87        let (status_code, headers) = f.into_recorded();
88        RenderedResponse {
89            html,
90            status_code,
91            headers,
92        }
93    }
94
95    /// Unwraps the view into its root part.
96    #[inline]
97    pub(crate) fn into_part(self) -> ViewPart {
98        self.part
99    }
100}
101
102/// The output of rendering a [`View`] for an HTTP response.
103///
104/// Returned by [`View::render_response`]: the rendered HTML alongside the
105/// status code and headers the view declared.
106#[cfg(feature = "http")]
107#[derive(Debug)]
108#[non_exhaustive]
109pub struct RenderedResponse {
110    /// The rendered HTML.
111    pub html: String,
112    /// The first status code the render encountered, if any.
113    pub status_code: Option<StatusCode>,
114    /// The collected response headers.
115    ///
116    /// Each name carries the values of the first render part that mentioned
117    /// it.
118    pub headers: HeaderMap,
119}
120
121/// A renderable value stored in a [`View`].
122///
123/// View parts are created through a [`PartsWriter`] or the `view!` macro. A
124/// part that holds text also records the [`HtmlContext`] it was written for,
125/// so rendering escapes or validates it for exactly that position.
126#[derive(Debug, Default, Clone)]
127#[non_exhaustive]
128pub enum ViewPart {
129    /// Renders no content.
130    #[default]
131    Empty,
132    /// A boolean rendered as text.
133    #[non_exhaustive]
134    Bool(bool),
135    /// An `i8` rendered as text.
136    #[non_exhaustive]
137    I8(i8),
138    /// An `i16` rendered as text.
139    #[non_exhaustive]
140    I16(i16),
141    /// An `i32` rendered as text.
142    #[non_exhaustive]
143    I32(i32),
144    /// An `i64` rendered as text.
145    #[non_exhaustive]
146    I64(i64),
147    /// An `i128` rendered as text.
148    #[non_exhaustive]
149    I128(i128),
150    /// An `isize` rendered as text.
151    #[non_exhaustive]
152    Isize(isize),
153    /// A `u8` rendered as text.
154    #[non_exhaustive]
155    U8(u8),
156    /// A `u16` rendered as text.
157    #[non_exhaustive]
158    U16(u16),
159    /// A `u32` rendered as text.
160    #[non_exhaustive]
161    U32(u32),
162    /// A `u64` rendered as text.
163    #[non_exhaustive]
164    U64(u64),
165    /// A `u128` rendered as text.
166    #[non_exhaustive]
167    U128(u128),
168    /// A `usize` rendered as text.
169    #[non_exhaustive]
170    Usize(usize),
171    /// An `f32` rendered as text.
172    #[non_exhaustive]
173    F32(f32),
174    /// An `f64` rendered as text.
175    #[non_exhaustive]
176    F64(f64),
177    /// A character rendered for the recorded context.
178    #[non_exhaustive]
179    Char { value: char, context: HtmlContext },
180    /// A string rendered for the recorded context.
181    #[non_exhaustive]
182    Str {
183        value: Cow<'static, str>,
184        context: HtmlContext,
185    },
186    /// A custom view part that writes its output at render time.
187    #[non_exhaustive]
188    BoxDyn {
189        inner: Box<dyn DynViewPart>,
190        context: HtmlContext,
191        size_hint: usize,
192    },
193    /// A sequence of view parts rendered in order.
194    #[non_exhaustive]
195    BoxSlice {
196        inner: Box<[ViewPart]>,
197        size_hint: usize,
198    },
199    /// A response status code recorded at render time; renders no content.
200    #[cfg(feature = "http")]
201    #[non_exhaustive]
202    StatusCode(StatusCode),
203    /// Response headers recorded at render time; renders no content.
204    #[cfg(feature = "http")]
205    #[non_exhaustive]
206    Headers(Box<HeaderMap>),
207}
208
209impl ViewPart {
210    /// Returns an empty view part.
211    #[inline]
212    #[must_use]
213    pub fn empty() -> Self {
214        Self::Empty
215    }
216
217    /// Returns `true` if the view part is [`Empty`].
218    ///
219    /// [`Empty`]: ViewPart::Empty
220    #[must_use]
221    pub fn is_empty(&self) -> bool {
222        matches!(self, Self::Empty)
223    }
224
225    /// Returns a part that renders `value` verbatim.
226    #[inline]
227    pub(crate) const fn unescaped(value: &'static str) -> Self {
228        Self::Str {
229            value: Cow::Borrowed(value),
230            context: HtmlContext::Unescaped,
231        }
232    }
233
234    /// Writes the part into `f`, escaped or validated for the context each
235    /// piece of text was written in.
236    pub(crate) fn render(&self, cx: &Cx, f: &mut Formatter<'_>) {
237        let mut int_buffer = itoa::Buffer::new();
238        let mut float_buffer = zmij::Buffer::new();
239
240        match self {
241            Self::Empty => {}
242            Self::Bool(inner) => f.write_str(if *inner { "true" } else { "false" }),
243            // The `Display` output of the numeric types consists of digits,
244            // signs, and plain letters, none of which are significant in any
245            // HTML context, so they write verbatim.
246            Self::I8(inner) => f.write_str(int_buffer.format(*inner)),
247            Self::I16(inner) => f.write_str(int_buffer.format(*inner)),
248            Self::I32(inner) => f.write_str(int_buffer.format(*inner)),
249            Self::I64(inner) => f.write_str(int_buffer.format(*inner)),
250            Self::I128(inner) => f.write_str(int_buffer.format(*inner)),
251            Self::Isize(inner) => f.write_str(int_buffer.format(*inner)),
252            Self::U8(inner) => f.write_str(int_buffer.format(*inner)),
253            Self::U16(inner) => f.write_str(int_buffer.format(*inner)),
254            Self::U32(inner) => f.write_str(int_buffer.format(*inner)),
255            Self::U64(inner) => f.write_str(int_buffer.format(*inner)),
256            Self::U128(inner) => f.write_str(int_buffer.format(*inner)),
257            Self::Usize(inner) => f.write_str(int_buffer.format(*inner)),
258            Self::F32(inner) => f.write_str(float_buffer.format(*inner)),
259            Self::F64(inner) => f.write_str(float_buffer.format(*inner)),
260            Self::Char { value, context } => context.writer(f).write_char(*value),
261            Self::Str { value, context } => context.writer(f).write_str(value),
262            Self::BoxDyn { inner, context, .. } => inner.render(cx, &mut context.writer(f)),
263            Self::BoxSlice { inner, .. } => {
264                for part in inner {
265                    part.render(cx, f);
266                }
267            }
268            #[cfg(feature = "http")]
269            Self::StatusCode(status_code) => f.record_status_code(*status_code),
270            #[cfg(feature = "http")]
271            Self::Headers(headers) => f.record_headers(headers),
272        }
273    }
274
275    /// Returns an estimate of the number of bytes this part will write.
276    ///
277    /// Used to pre-allocate the output buffer. A slight over-estimate is
278    /// preferable to an under-estimate: falling short forces the buffer to
279    /// grow and copy, whereas a modest over-estimate only leaves a little
280    /// capacity unused.
281    pub(crate) fn size_hint(&self) -> usize {
282        // Each numeric hint is the midpoint, rounded up, between the shortest
283        // and widest output the type can `Display`, including the leading `-`
284        // for signed types (`isize`/`usize` assume a 64-bit target). A
285        // float's `Display` width is unbounded for extreme magnitudes, so the
286        // upper end is the shortest round-trip form of a typical value.
287        #[allow(clippy::match_same_arms)]
288        match self {
289            Self::Empty => 0,
290            Self::Bool(_) => 5,
291            Self::I8(_) => 3,
292            Self::I16(_) => 4,
293            Self::I32(_) => 6,
294            Self::I64(_) => 11,
295            Self::I128(_) => 21,
296            Self::Isize(_) => 11,
297            Self::U8(_) => 2,
298            Self::U16(_) => 3,
299            Self::U32(_) => 6,
300            Self::U64(_) => 11,
301            Self::U128(_) => 20,
302            Self::Usize(_) => 11,
303            Self::F32(_) => 9,
304            Self::F64(_) => 13,
305            // One to four UTF-8 bytes, or an escape sequence.
306            Self::Char { .. } => 3,
307            Self::Str { value, context } => match context {
308                HtmlContext::Unescaped => value.len(),
309                // Assume some characters escape into multi-byte sequences.
310                _ => value.len() + value.len() / 8,
311            },
312            Self::BoxDyn { size_hint, .. } | Self::BoxSlice { size_hint, .. } => *size_hint,
313            #[cfg(feature = "http")]
314            Self::StatusCode(_) | Self::Headers(_) => 0,
315        }
316    }
317}
318
319/// A boxed view part that writes its output at render time.
320///
321/// Implement this for values whose output is only known when the view
322/// renders, such as resolved asset URLs. The writer passed to
323/// [`render`](Self::render) already carries the [`HtmlContext`] of the
324/// position the part was pushed into, so everything written through it is
325/// escaped or validated for that position.
326pub trait DynViewPart: 'static + fmt::Debug + Send {
327    /// Writes this part's output into `w`.
328    fn render(&self, cx: &Cx, w: &mut HtmlWriter<'_, '_>);
329
330    /// Returns an estimate of the number of bytes this part will write.
331    ///
332    /// Used to pre-allocate the output buffer, so aim for a close estimate. A
333    /// slight over-estimate is usually preferable to an under-estimate.
334    #[inline]
335    fn size_hint(&self) -> usize {
336        0
337    }
338
339    /// Clones this view part into a fresh boxed value.
340    fn clone_box(&self) -> Box<dyn DynViewPart>;
341}
342
343impl Clone for Box<dyn DynViewPart> {
344    #[inline]
345    fn clone(&self) -> Self {
346        (**self).clone_box()
347    }
348}
349
350/// A buffer collecting renderable values before they become a [`View`].
351///
352/// This is plumbing for generated `view!` code, which fills the buffer
353/// through [`PartsWriter`] lenses and the position helpers and finally passes
354/// it to `View::new`.
355#[doc(hidden)]
356#[derive(Debug, Default, Clone)]
357pub struct ViewParts {
358    items: SmallVec<[ViewPart; 8]>,
359}
360
361impl ViewParts {
362    /// Creates an empty view-parts buffer.
363    #[inline]
364    #[must_use]
365    pub fn new() -> Self {
366        Self::default()
367    }
368
369    /// Appends a nested view, such as a rendered component.
370    #[inline]
371    pub fn push_view(&mut self, view: View) -> &mut Self {
372        self.items.push(view.into_part());
373        self
374    }
375
376    /// Appends an already-sealed view part.
377    ///
378    /// A part records the [`HtmlContext`] its text was written for. Pushing
379    /// it into a position with different escaping requirements bypasses that
380    /// protection, so this is reserved for framework plumbing that re-emits
381    /// parts in the position family they were built for.
382    #[doc(hidden)]
383    #[inline]
384    pub fn push_part(&mut self, part: ViewPart) -> &mut Self {
385        self.items.push(part);
386        self
387    }
388}
389
390impl From<ViewParts> for ViewPart {
391    #[inline]
392    fn from(mut value: ViewParts) -> Self {
393        match value.items.len() {
394            0 => ViewPart::Empty,
395            1 => value.items.pop().unwrap(),
396            _ => {
397                let size_hint = value.items.iter().map(ViewPart::size_hint).sum();
398                ViewPart::BoxSlice {
399                    inner: value.items.into_boxed_slice(),
400                    size_hint,
401                }
402            }
403        }
404    }
405}
406
407macro_rules! impl_push_primitive {
408    ($method:ident, $ty:ty, $variant:ident) => {
409        #[doc = concat!("Appends a `", stringify!($ty), "` rendered as text.")]
410        ///
411        /// Its rendered form contains no character that is significant in any
412        /// HTML context, so no escaping applies.
413        #[inline]
414        pub fn $method(&mut self, value: $ty) -> &mut Self {
415            self.parts.items.push(ViewPart::$variant(value));
416            self
417        }
418    };
419}
420
421/// A context-carrying writer over a view-parts buffer, created per position.
422///
423/// The `view!` macro creates a `PartsWriter` for each dynamic position it
424/// fills and hands it to the matching position trait:
425/// [`NodeViewParts`](crate::NodeViewParts),
426/// [`AttributeValueViewParts`](crate::AttributeValueViewParts),
427/// [`AttributeKeyViewParts`](crate::AttributeKeyViewParts),
428/// [`ElementNameViewParts`](crate::ElementNameViewParts), or
429/// [`AttributeViewParts`](crate::AttributeViewParts).
430///
431/// Implementations of those traits make a value renderable by pushing it
432/// through the `push_*` methods, which seal the pushed text with the
433/// [`HtmlContext`] of the position so rendering escapes or validates it
434/// correctly, or by delegating to another implementation of the same
435/// position trait. [`push_str_unescaped`](Self::push_str_unescaped) is the
436/// only way to opt out of that protection.
437pub struct PartsWriter<'a> {
438    parts: &'a mut ViewParts,
439    context: HtmlContext,
440}
441
442impl<'a> PartsWriter<'a> {
443    /// Creates a writer that seals everything pushed into it with `context`.
444    #[inline]
445    pub fn new(parts: &'a mut ViewParts, context: HtmlContext) -> Self {
446        Self { parts, context }
447    }
448
449    /// Returns a writer over the same buffer for a different context.
450    ///
451    /// This is how in-crate compositions such as
452    /// [`Attribute`](crate::Attribute) transition between the
453    /// positions they span.
454    #[inline]
455    pub(crate) fn with_context(&mut self, context: HtmlContext) -> PartsWriter<'_> {
456        PartsWriter {
457            parts: self.parts,
458            context,
459        }
460    }
461
462    /// Appends a string, sealed with this writer's context.
463    #[inline]
464    pub fn push_str(&mut self, value: impl Into<Cow<'static, str>>) -> &mut Self {
465        self.parts.items.push(ViewPart::Str {
466            value: value.into(),
467            context: self.context,
468        });
469        self
470    }
471
472    /// Appends a string that renders verbatim, bypassing this writer's
473    /// context.
474    ///
475    /// Use this only for trusted markup. Passing untrusted input defeats the
476    /// runtime's escaping and can lead to XSS vulnerabilities.
477    #[inline]
478    pub fn push_str_unescaped(&mut self, value: impl Into<Cow<'static, str>>) -> &mut Self {
479        self.parts.items.push(ViewPart::Str {
480            value: value.into(),
481            context: HtmlContext::Unescaped,
482        });
483        self
484    }
485
486    /// Appends a character, sealed with this writer's context.
487    #[inline]
488    pub fn push_char(&mut self, value: char) -> &mut Self {
489        self.parts.items.push(ViewPart::Char {
490            value,
491            context: self.context,
492        });
493        self
494    }
495
496    impl_push_primitive!(push_bool, bool, Bool);
497    impl_push_primitive!(push_i8, i8, I8);
498    impl_push_primitive!(push_i16, i16, I16);
499    impl_push_primitive!(push_i32, i32, I32);
500    impl_push_primitive!(push_i64, i64, I64);
501    impl_push_primitive!(push_i128, i128, I128);
502    impl_push_primitive!(push_isize, isize, Isize);
503    impl_push_primitive!(push_u8, u8, U8);
504    impl_push_primitive!(push_u16, u16, U16);
505    impl_push_primitive!(push_u32, u32, U32);
506    impl_push_primitive!(push_u64, u64, U64);
507    impl_push_primitive!(push_u128, u128, U128);
508    impl_push_primitive!(push_usize, usize, Usize);
509    impl_push_primitive!(push_f32, f32, F32);
510    impl_push_primitive!(push_f64, f64, F64);
511
512    /// Appends a part that writes its output at render time, sealed with
513    /// this writer's context.
514    #[inline]
515    pub fn push_dyn(&mut self, part: Box<dyn DynViewPart>) -> &mut Self {
516        self.parts.items.push(ViewPart::BoxDyn {
517            size_hint: part.size_hint(),
518            inner: part,
519            context: self.context,
520        });
521        self
522    }
523
524    /// Appends an already-sealed view part.
525    ///
526    /// A part records the [`HtmlContext`] its text was written for; this
527    /// writer's context does not apply. See [`ViewParts::push_part`].
528    #[doc(hidden)]
529    #[inline]
530    pub fn push_part(&mut self, part: ViewPart) -> &mut Self {
531        self.parts.items.push(part);
532        self
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539
540    fn render(build: impl FnOnce(&mut ViewParts)) -> String {
541        let mut parts = ViewParts::new();
542        build(&mut parts);
543        View::new(parts).render(&Cx::default())
544    }
545
546    #[test]
547    fn empty_view_renders_empty() {
548        assert_eq!(View::empty().render(&Cx::default()), "");
549    }
550
551    #[test]
552    fn unescaped_unchecked_renders_verbatim() {
553        let view = View::unescaped_unchecked("<b>raw</b>");
554        assert_eq!(view.render(&Cx::default()), "<b>raw</b>");
555    }
556
557    #[test]
558    fn push_str_seals_the_writer_context() {
559        let out = render(|parts| {
560            PartsWriter::new(parts, HtmlContext::Text).push_str("<b> & \"q\"");
561        });
562        assert_eq!(out, "&lt;b&gt; &amp; \"q\"");
563
564        let out = render(|parts| {
565            PartsWriter::new(parts, HtmlContext::AttributeValue).push_str("<b> & \"q\"");
566        });
567        assert_eq!(out, "<b> &amp; &quot;q&quot;");
568    }
569
570    #[test]
571    fn push_str_unescaped_bypasses_the_context() {
572        let out = render(|parts| {
573            PartsWriter::new(parts, HtmlContext::Text).push_str_unescaped("<b>raw</b>");
574        });
575        assert_eq!(out, "<b>raw</b>");
576    }
577
578    #[test]
579    fn push_char_seals_the_writer_context() {
580        let out = render(|parts| {
581            PartsWriter::new(parts, HtmlContext::Text).push_char('<');
582        });
583        assert_eq!(out, "&lt;");
584    }
585
586    #[test]
587    #[should_panic(expected = "invalid attribute key")]
588    fn ident_context_panics_on_forbidden_characters_at_render() {
589        render(|parts| {
590            PartsWriter::new(parts, HtmlContext::AttributeKey).push_str("on click");
591        });
592    }
593
594    #[test]
595    fn push_primitives_render_as_text() {
596        let out = render(|parts| {
597            let mut writer = PartsWriter::new(parts, HtmlContext::Text);
598            writer.push_i32(-42).push_str_unescaped(" ");
599            writer.push_bool(true).push_str_unescaped(" ");
600            writer.push_f64(1.5);
601        });
602        assert_eq!(out, "-42 true 1.5");
603    }
604
605    #[test]
606    fn push_view_splices_nested_views() {
607        let mut inner_parts = ViewParts::new();
608        PartsWriter::new(&mut inner_parts, HtmlContext::Text).push_str("a < b");
609        let inner = View::new(inner_parts);
610
611        let out = render(|parts| {
612            PartsWriter::new(parts, HtmlContext::Unescaped).push_str("<p>");
613            parts.push_view(inner);
614            PartsWriter::new(parts, HtmlContext::Unescaped).push_str("</p>");
615        });
616        assert_eq!(out, "<p>a &lt; b</p>");
617    }
618
619    #[test]
620    fn size_hint_is_exact_for_unescaped_strings() {
621        let view = View::unescaped_unchecked("<b>raw</b>");
622        assert_eq!(view.part.size_hint(), 10);
623    }
624
625    #[cfg(feature = "http")]
626    mod response {
627        use http::header::{CACHE_CONTROL, SET_COOKIE};
628        use http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
629
630        use super::*;
631        use crate::NodeViewParts;
632
633        fn push_node(parts: &mut ViewParts, value: impl NodeViewParts) {
634            value.into_view_parts(
635                &Cx::default(),
636                &mut PartsWriter::new(parts, HtmlContext::Text),
637            );
638        }
639
640        fn push_text(parts: &mut ViewParts, text: &'static str) {
641            PartsWriter::new(parts, HtmlContext::Text).push_str(text);
642        }
643
644        #[test]
645        fn status_code_is_recorded_and_renders_nothing() {
646            let mut parts = ViewParts::new();
647            push_text(&mut parts, "a");
648            push_node(&mut parts, StatusCode::NOT_FOUND);
649            push_text(&mut parts, "b");
650
651            let rendered = View::new(parts).render_response(&Cx::default());
652            assert_eq!(rendered.html, "ab");
653            assert_eq!(rendered.status_code, Some(StatusCode::NOT_FOUND));
654            assert!(rendered.headers.is_empty());
655        }
656
657        #[test]
658        fn render_response_without_declarations_is_empty() {
659            let mut parts = ViewParts::new();
660            push_text(&mut parts, "a");
661
662            let rendered = View::new(parts).render_response(&Cx::default());
663            assert_eq!(rendered.html, "a");
664            assert_eq!(rendered.status_code, None);
665            assert!(rendered.headers.is_empty());
666        }
667
668        #[test]
669        fn render_discards_declarations() {
670            let mut parts = ViewParts::new();
671            push_node(&mut parts, StatusCode::NOT_FOUND);
672            push_node(
673                &mut parts,
674                (CACHE_CONTROL, HeaderValue::from_static("no-store")),
675            );
676            push_text(&mut parts, "a");
677
678            assert_eq!(View::new(parts).render(&Cx::default()), "a");
679        }
680
681        #[test]
682        fn first_status_code_wins() {
683            let mut parts = ViewParts::new();
684            push_node(&mut parts, StatusCode::NOT_FOUND);
685            push_node(&mut parts, StatusCode::OK);
686
687            let rendered = View::new(parts).render_response(&Cx::default());
688            assert_eq!(rendered.status_code, Some(StatusCode::NOT_FOUND));
689        }
690
691        #[test]
692        fn first_mention_of_a_header_name_wins() {
693            let mut parts = ViewParts::new();
694            push_node(
695                &mut parts,
696                (CACHE_CONTROL, HeaderValue::from_static("no-store")),
697            );
698            let mut later = HeaderMap::new();
699            later.insert(CACHE_CONTROL, HeaderValue::from_static("max-age=60"));
700            later.insert(
701                HeaderName::from_static("x-extra"),
702                HeaderValue::from_static("1"),
703            );
704            push_node(&mut parts, later);
705
706            let rendered = View::new(parts).render_response(&Cx::default());
707            assert_eq!(rendered.headers[CACHE_CONTROL], "no-store");
708            assert_eq!(rendered.headers["x-extra"], "1");
709        }
710
711        #[test]
712        fn one_map_keeps_all_values_for_a_name() {
713            let mut first = HeaderMap::new();
714            first.append(SET_COOKIE, HeaderValue::from_static("a=1"));
715            first.append(SET_COOKIE, HeaderValue::from_static("b=2"));
716            let mut later = HeaderMap::new();
717            later.insert(SET_COOKIE, HeaderValue::from_static("c=3"));
718
719            let mut parts = ViewParts::new();
720            push_node(&mut parts, first);
721            push_node(&mut parts, later);
722
723            let rendered = View::new(parts).render_response(&Cx::default());
724            let cookies: Vec<_> = rendered.headers.get_all(SET_COOKIE).iter().collect();
725            assert_eq!(cookies, ["a=1", "b=2"]);
726        }
727
728        #[test]
729        fn placement_decides_precedence_across_nested_views() {
730            let mut inner_parts = ViewParts::new();
731            push_node(&mut inner_parts, StatusCode::NOT_FOUND);
732            push_text(&mut inner_parts, "inner");
733            let inner = View::new(inner_parts);
734
735            // A status code before the nested view overrides it.
736            let mut outer_parts = ViewParts::new();
737            push_node(&mut outer_parts, StatusCode::FORBIDDEN);
738            outer_parts.push_view(inner.clone());
739            let rendered = View::new(outer_parts).render_response(&Cx::default());
740            assert_eq!(rendered.status_code, Some(StatusCode::FORBIDDEN));
741
742            // A status code after the nested view is only a fallback.
743            let mut outer_parts = ViewParts::new();
744            outer_parts.push_view(inner);
745            push_node(&mut outer_parts, StatusCode::FORBIDDEN);
746            let rendered = View::new(outer_parts).render_response(&Cx::default());
747            assert_eq!(rendered.status_code, Some(StatusCode::NOT_FOUND));
748        }
749    }
750}