1use crate::{route_matcher, route_renderer, HandlerError, StateSelection};
12use route_matcher::CompiledRouteIndex;
13use serde::de::{DeserializeSeed, IgnoredAny, MapAccess, SeqAccess, Visitor};
14use serde::ser::SerializeMap;
15use serde::Serialize;
16use serde_json::{value::RawValue, Map, Value};
17use std::borrow::Cow;
18use std::collections::{HashMap, HashSet};
19use std::fmt;
20use std::sync::{OnceLock, RwLock};
21use webui_protocol::{web_ui_fragment::Fragment, WebUIFragmentRoute, WebUIProtocol};
22
23pub struct Protocol {
35 protocol: WebUIProtocol,
36 component_index: HashMap<String, u32>,
37 component_reachability: OnceLock<ComponentReachabilityIndex>,
38 streaming_plans: HashMap<String, crate::streaming::PreparedStreamingEntryPlan>,
39 route_index: CompiledRouteIndex,
40 legacy_structural_signals: bool,
41 template_metadata_cache: RwLock<HashMap<String, Value>>,
42}
43
44impl Protocol {
45 pub fn from_protobuf(bytes: &[u8]) -> std::result::Result<Self, webui_protocol::ProtocolError> {
51 WebUIProtocol::from_protobuf(bytes).map(Self::new)
52 }
53
54 #[must_use]
56 pub fn new(protocol: WebUIProtocol) -> Self {
57 let component_index = build_component_index(&protocol);
58 let route_index = CompiledRouteIndex::new(&protocol);
59 let streaming_plans = protocol
60 .streaming_boundaries
61 .keys()
62 .filter_map(|entry| {
63 protocol.fragments.get(entry).map(|fragments| {
64 (
65 entry.clone(),
66 crate::streaming::PreparedStreamingEntryPlan::new(
67 entry,
68 &fragments.fragments,
69 protocol.streaming_boundaries.get(entry),
70 ),
71 )
72 })
73 })
74 .collect();
75 let legacy_structural_signals = !protocol.fragments.values().any(|list| {
76 list.fragments.iter().any(|fragment| {
77 matches!(
78 fragment.fragment.as_ref(),
79 Some(Fragment::Signal(signal))
80 if signal.raw
81 && signal.value.starts_with(crate::STRUCTURAL_SIGNAL_PREFIX)
82 )
83 })
84 });
85 Self {
86 protocol,
87 component_index,
88 component_reachability: OnceLock::new(),
89 streaming_plans,
90 route_index,
91 legacy_structural_signals,
92 template_metadata_cache: RwLock::new(HashMap::new()),
93 }
94 }
95
96 pub(crate) fn protocol(&self) -> &WebUIProtocol {
97 &self.protocol
98 }
99
100 pub(crate) fn component_index(&self) -> &HashMap<String, u32> {
101 &self.component_index
102 }
103
104 pub(crate) fn component_reachability(&self) -> &ComponentReachabilityIndex {
105 self.component_reachability
106 .get_or_init(|| ComponentReachabilityIndex::new(&self.protocol, &self.component_index))
107 }
108
109 pub(crate) fn streaming_plan(
110 &self,
111 entry_id: &str,
112 ) -> crate::Result<Option<&crate::streaming::StreamingEntryPlan>> {
113 self.streaming_plans
114 .get(entry_id)
115 .map_or(Ok(None), |plan| plan.resolve().map(Some))
116 }
117
118 pub(crate) fn streaming_boundary_names(&self, entry_id: &str) -> &[String] {
119 self.protocol
120 .streaming_boundaries
121 .get(entry_id)
122 .map_or(&[], |boundaries| boundaries.names.as_slice())
123 }
124
125 pub(crate) fn route_index(&self) -> &CompiledRouteIndex {
126 &self.route_index
127 }
128
129 pub(crate) fn legacy_structural_signals(&self) -> bool {
130 self.legacy_structural_signals
131 }
132
133 #[must_use]
135 pub fn tokens(&self) -> &[String] {
136 &self.protocol.tokens
137 }
138
139 pub fn render_partial(
141 &self,
142 state_json: &str,
143 entry_id: &str,
144 request_path: &str,
145 inventory_hex: &str,
146 ) -> Result<String, HandlerError> {
147 let mut index = self.request_index();
148 let (response, state_selection) = render_partial_indexed_with_state(
149 self.protocol(),
150 entry_id,
151 request_path,
152 inventory_hex,
153 &mut index,
154 )?;
155 serialize_partial_response(&response, state_json, &state_selection)
156 }
157
158 pub fn render_component_templates(
160 &self,
161 component_tags: &[&str],
162 inventory_hex: &str,
163 ) -> Result<Value, HandlerError> {
164 let mut index = self.request_index();
165 render_component_templates_indexed(
166 self.protocol(),
167 component_tags,
168 inventory_hex,
169 &mut index,
170 )
171 }
172
173 #[cfg(test)]
174 fn render_partial_metadata(
175 &self,
176 entry_id: &str,
177 request_path: &str,
178 inventory_hex: &str,
179 ) -> Result<Value, HandlerError> {
180 let mut index = self.request_index();
181 render_partial_indexed(
182 self.protocol(),
183 entry_id,
184 request_path,
185 inventory_hex,
186 &mut index,
187 )
188 }
189
190 fn request_index(&self) -> RequestProtocolIndex<'_> {
191 RequestProtocolIndex {
192 component_index: &self.component_index,
193 route_index: &self.route_index,
194 template_metadata_cache: TemplateMetadataCache::Shared(&self.template_metadata_cache),
195 }
196 }
197}
198
199#[cfg(test)]
205struct ProtocolIndex {
206 pub component_index: HashMap<String, u32>,
208 pub route_index: CompiledRouteIndex,
210 template_metadata_cache: HashMap<String, Value>,
212}
213
214struct ComponentAssets {
215 styles: Vec<Value>,
216 templates: serde_json::Map<String, Value>,
217 functions: serde_json::Map<String, Value>,
218}
219
220#[cfg(test)]
221impl ProtocolIndex {
222 #[must_use]
224 pub(crate) fn new(protocol: &WebUIProtocol) -> Self {
225 Self {
226 component_index: build_component_index(protocol),
227 route_index: CompiledRouteIndex::new(protocol),
228 template_metadata_cache: HashMap::new(),
229 }
230 }
231
232 fn request_index(&mut self) -> RequestProtocolIndex<'_> {
233 RequestProtocolIndex {
234 component_index: &self.component_index,
235 route_index: &self.route_index,
236 template_metadata_cache: TemplateMetadataCache::Exclusive(
237 &mut self.template_metadata_cache,
238 ),
239 }
240 }
241}
242
243enum TemplateMetadataCache<'a> {
244 #[cfg(test)]
245 Exclusive(&'a mut HashMap<String, Value>),
246 Shared(&'a RwLock<HashMap<String, Value>>),
247}
248
249struct RequestProtocolIndex<'a> {
250 component_index: &'a HashMap<String, u32>,
251 route_index: &'a CompiledRouteIndex,
252 template_metadata_cache: TemplateMetadataCache<'a>,
253}
254
255pub(crate) fn build_component_index(protocol: &WebUIProtocol) -> HashMap<String, u32> {
264 let mut sorted: Vec<&String> = protocol
265 .fragments
266 .keys()
267 .filter(|key| key.contains('-'))
268 .collect();
269 sorted.sort_unstable();
270 let mut index = HashMap::with_capacity(sorted.len());
271 for (i, name) in sorted.into_iter().enumerate() {
272 #[allow(clippy::cast_possible_truncation)]
274 let idx = i as u32;
275 index.insert(name.clone(), idx);
276 }
277 index
278}
279
280pub(crate) struct ComponentReachabilityIndex {
285 names: Vec<String>,
286 dependencies: Vec<Box<[u32]>>,
287 route_dependent: Vec<bool>,
288}
289
290impl ComponentReachabilityIndex {
291 fn new(protocol: &WebUIProtocol, component_index: &HashMap<String, u32>) -> Self {
292 let mut names: Vec<String> = component_index.keys().cloned().collect();
293 names.sort_unstable();
294
295 let mut dependencies = Vec::with_capacity(names.len());
296 let mut route_dependent = Vec::with_capacity(names.len());
297 for name in &names {
298 let (direct, has_route) =
299 collect_direct_component_dependencies(protocol, name, component_index);
300 dependencies.push(direct.into_boxed_slice());
301 route_dependent.push(has_route);
302 }
303 propagate_route_dependencies(&dependencies, &mut route_dependent);
304
305 Self {
306 names,
307 dependencies,
308 route_dependent,
309 }
310 }
311
312 pub(crate) fn name(&self, index: u32) -> Option<&str> {
313 self.names.get(index as usize).map(String::as_str)
314 }
315
316 pub(crate) fn dependencies(&self, index: u32) -> Option<&[u32]> {
317 self.dependencies.get(index as usize).map(Box::as_ref)
318 }
319
320 pub(crate) fn is_route_dependent(&self, index: u32) -> Option<bool> {
321 self.route_dependent.get(index as usize).copied()
322 }
323
324 pub(crate) fn requires_expansion(&self, index: u32) -> Option<bool> {
325 Some(self.is_route_dependent(index)? || !self.dependencies.get(index as usize)?.is_empty())
326 }
327}
328
329enum ComponentDependencyWork<'a> {
330 Fragment(&'a str),
331 Component(u32),
332}
333
334fn collect_direct_component_dependencies(
335 protocol: &WebUIProtocol,
336 root: &str,
337 component_index: &HashMap<String, u32>,
338) -> (Vec<u32>, bool) {
339 let mut work = vec![ComponentDependencyWork::Fragment(root)];
340 let mut visited_fragments = HashSet::new();
341 let mut seen_components = HashSet::new();
342 let mut dependencies = Vec::new();
343 let mut has_route = false;
344
345 while let Some(item) = work.pop() {
346 match item {
347 ComponentDependencyWork::Component(index) => {
348 if seen_components.insert(index) {
349 dependencies.push(index);
350 }
351 }
352 ComponentDependencyWork::Fragment(id) => {
353 if !visited_fragments.insert(id) {
354 continue;
355 }
356 let Some(list) = protocol.fragments.get(id) else {
357 continue;
358 };
359 for fragment in list.fragments.iter().rev() {
360 match fragment.fragment.as_ref() {
361 Some(Fragment::Component(component)) => {
362 if let Some(&index) = component_index.get(&component.fragment_id) {
363 work.push(ComponentDependencyWork::Component(index));
364 }
365 }
366 Some(Fragment::ForLoop(for_loop)) => work.push(
367 ComponentDependencyWork::Fragment(for_loop.fragment_id.as_str()),
368 ),
369 Some(Fragment::IfCond(if_cond)) => work.push(
370 ComponentDependencyWork::Fragment(if_cond.fragment_id.as_str()),
371 ),
372 Some(Fragment::Attribute(attribute)) if !attribute.template.is_empty() => {
373 work.push(ComponentDependencyWork::Fragment(
374 attribute.template.as_str(),
375 ));
376 }
377 Some(Fragment::Route(_)) => has_route = true,
378 _ => {}
379 }
380 }
381 }
382 }
383 }
384
385 (dependencies, has_route)
386}
387
388fn propagate_route_dependencies(dependencies: &[Box<[u32]>], route_dependent: &mut [bool]) {
389 let mut reverse = vec![Vec::new(); dependencies.len()];
390 for (parent, children) in dependencies.iter().enumerate() {
391 for &child in children.iter() {
392 if let Some(parents) = reverse.get_mut(child as usize) {
393 #[allow(clippy::cast_possible_truncation)]
394 parents.push(parent as u32);
395 }
396 }
397 }
398
399 let mut pending = Vec::new();
400 for (index, &depends_on_route) in route_dependent.iter().enumerate() {
401 if depends_on_route {
402 #[allow(clippy::cast_possible_truncation)]
403 pending.push(index as u32);
404 }
405 }
406 while let Some(index) = pending.pop() {
407 let Some(parents) = reverse.get(index as usize) else {
408 continue;
409 };
410 for &parent in parents {
411 let Some(depends_on_route) = route_dependent.get_mut(parent as usize) else {
412 continue;
413 };
414 if !*depends_on_route {
415 *depends_on_route = true;
416 pending.push(parent);
417 }
418 }
419 }
420}
421
422fn has_component(inventory: &[u8], index: u32) -> bool {
424 let byte_idx = (index / 8) as usize;
425 let bit_idx = index % 8;
426 byte_idx < inventory.len() && inventory[byte_idx] & (1 << bit_idx) != 0
427}
428
429fn set_component(inventory: &mut Vec<u8>, index: u32) {
431 let byte_idx = (index / 8) as usize;
432 let bit_idx = index % 8;
433 if byte_idx >= inventory.len() {
434 inventory.resize(byte_idx + 1, 0);
435 }
436 inventory[byte_idx] |= 1 << bit_idx;
437}
438
439pub fn parse_inventory(hex: &str) -> Result<Vec<u8>, HandlerError> {
443 if hex.is_empty() {
444 return Ok(Vec::new());
445 }
446 if !hex.len().is_multiple_of(2) {
447 return Err(HandlerError::Protocol(
448 webui_protocol::ProtocolError::Validation(format!(
449 "inventory hex has odd length: {}",
450 hex.len()
451 )),
452 ));
453 }
454 let mut bytes = Vec::with_capacity(hex.len() / 2);
455 let mut chars = hex.bytes();
456 while let (Some(hi), Some(lo)) = (chars.next(), chars.next()) {
457 let h = route_matcher::hex_val(hi).ok_or_else(|| {
458 HandlerError::Protocol(webui_protocol::ProtocolError::Validation(format!(
459 "invalid hex digit in inventory: {:#04x}",
460 hi
461 )))
462 })?;
463 let l = route_matcher::hex_val(lo).ok_or_else(|| {
464 HandlerError::Protocol(webui_protocol::ProtocolError::Validation(format!(
465 "invalid hex digit in inventory: {:#04x}",
466 lo
467 )))
468 })?;
469 bytes.push((h << 4) | l);
470 }
471 Ok(bytes)
472}
473
474pub fn encode_inventory(inv: &[u8]) -> String {
476 const HEX: &[u8; 16] = b"0123456789abcdef";
477 let mut encoded = String::with_capacity(inv.len() * 2);
478 for byte in inv {
479 encoded.push(char::from(HEX[usize::from(byte >> 4)]));
480 encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
481 }
482 encoded
483}
484
485pub(crate) fn encode_component_inventory(
487 component_names: &HashSet<String>,
488 index: &HashMap<String, u32>,
489) -> String {
490 let mut inventory = Vec::new();
491 for name in component_names {
492 if let Some(&idx) = index.get(name.as_str()) {
493 set_component(&mut inventory, idx);
494 }
495 }
496 encode_inventory(&inventory)
497}
498
499pub fn get_needed_components(
504 protocol: &WebUIProtocol,
505 entry_id: &str,
506 inventory_hex: &str,
507 component_index: &HashMap<String, u32>,
508) -> Result<(Vec<String>, String), HandlerError> {
509 let route_index = CompiledRouteIndex::new(protocol);
510 let component_names =
511 collect_inventoryable_components(protocol, entry_id, None, true, &route_index);
512 filter_needed_components(&component_names, inventory_hex, component_index)
513}
514
515pub(crate) fn get_needed_components_for_request(
520 protocol: &WebUIProtocol,
521 entry_id: &str,
522 request_path: &str,
523 inventory_hex: &str,
524 indexes: (&HashMap<String, u32>, &CompiledRouteIndex),
525) -> Result<(Vec<String>, String), HandlerError> {
526 let (component_index, route_index) = indexes;
527 let component_names = collect_inventoryable_components(
528 protocol,
529 entry_id,
530 Some(request_path),
531 false,
532 route_index,
533 );
534 filter_needed_components(&component_names, inventory_hex, component_index)
535}
536
537fn serialize_partial_response(
538 response: &Value,
539 state_json: &str,
540 state_selection: &StateSelection<'_>,
541) -> Result<String, HandlerError> {
542 let response = response
543 .as_object()
544 .ok_or_else(partial_response_not_object)?;
545 let state = select_raw_state(state_json, state_selection)?;
546 serde_json::to_string(&PartialResponseWithState { response, state })
547 .map_err(|error| partial_serialize_error(&error.to_string()))
548}
549
550fn validate_json(json: &str) -> Result<(), HandlerError> {
551 validate_json_inner(json).map_err(|error| invalid_state_json(&error.to_string()))
552}
553
554fn validate_json_inner(json: &str) -> serde_json::Result<()> {
555 let mut deserializer = serde_json::Deserializer::from_str(json);
556 ValidJson
557 .deserialize(&mut deserializer)
558 .and_then(|_| deserializer.end())
559}
560
561struct ValidJson;
562
563impl<'de> DeserializeSeed<'de> for ValidJson {
564 type Value = ();
565
566 fn deserialize<D>(self, deserializer: D) -> std::result::Result<(), D::Error>
567 where
568 D: serde::Deserializer<'de>,
569 {
570 deserializer.deserialize_any(ValidJsonVisitor)
571 }
572}
573
574struct ValidJsonVisitor;
575
576impl<'de> Visitor<'de> for ValidJsonVisitor {
577 type Value = ();
578
579 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
580 formatter.write_str("valid JSON")
581 }
582
583 fn visit_bool<E>(self, _value: bool) -> std::result::Result<(), E> {
584 Ok(())
585 }
586
587 fn visit_i64<E>(self, _value: i64) -> std::result::Result<(), E> {
588 Ok(())
589 }
590
591 fn visit_u64<E>(self, _value: u64) -> std::result::Result<(), E> {
592 Ok(())
593 }
594
595 fn visit_f64<E>(self, value: f64) -> std::result::Result<(), E>
596 where
597 E: serde::de::Error,
598 {
599 if value.is_finite() {
600 Ok(())
601 } else {
602 Err(E::custom("number out of range"))
603 }
604 }
605
606 fn visit_str<E>(self, _value: &str) -> std::result::Result<(), E> {
607 Ok(())
608 }
609
610 fn visit_borrowed_str<E>(self, _value: &'de str) -> std::result::Result<(), E> {
611 Ok(())
612 }
613
614 fn visit_string<E>(self, _value: String) -> std::result::Result<(), E> {
615 Ok(())
616 }
617
618 fn visit_unit<E>(self) -> std::result::Result<(), E> {
619 Ok(())
620 }
621
622 fn visit_none<E>(self) -> std::result::Result<(), E> {
623 Ok(())
624 }
625
626 fn visit_some<D>(self, deserializer: D) -> std::result::Result<(), D::Error>
627 where
628 D: serde::Deserializer<'de>,
629 {
630 ValidJson.deserialize(deserializer)
631 }
632
633 fn visit_seq<A>(self, mut sequence: A) -> std::result::Result<(), A::Error>
634 where
635 A: SeqAccess<'de>,
636 {
637 while sequence.next_element_seed(ValidJson)?.is_some() {}
638 Ok(())
639 }
640
641 fn visit_map<A>(self, mut map: A) -> std::result::Result<(), A::Error>
642 where
643 A: MapAccess<'de>,
644 {
645 while map.next_key::<IgnoredAny>()?.is_some() {
646 map.next_value_seed(ValidJson)?;
647 }
648 Ok(())
649 }
650}
651
652struct PartialResponseWithState<'a, 'state> {
653 response: &'a Map<String, Value>,
654 state: SelectedRawState<'state>,
655}
656
657impl Serialize for PartialResponseWithState<'_, '_> {
658 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
659 where
660 S: serde::Serializer,
661 {
662 let mut map = serializer.serialize_map(Some(self.response.len().saturating_add(1)))?;
663 for (key, value) in self.response {
664 map.serialize_entry(key, value)?;
665 }
666 map.serialize_entry("state", &self.state)?;
667 map.end()
668 }
669}
670
671struct ProjectedRawState<'de> {
672 entries: Vec<(Cow<'de, str>, &'de RawValue)>,
673}
674
675enum SelectedRawState<'de> {
676 Full(&'de RawValue),
677 Keys(ProjectedRawState<'de>),
678}
679
680impl Serialize for SelectedRawState<'_> {
681 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
682 where
683 S: serde::Serializer,
684 {
685 match self {
686 Self::Full(state) => state.serialize(serializer),
687 Self::Keys(state) => state.serialize(serializer),
688 }
689 }
690}
691
692impl Serialize for ProjectedRawState<'_> {
693 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
694 where
695 S: serde::Serializer,
696 {
697 let mut map = serializer.serialize_map(Some(self.entries.len()))?;
698 for (key, value) in &self.entries {
699 map.serialize_entry(key.as_ref(), value)?;
700 }
701 map.end()
702 }
703}
704
705fn project_raw_state<'de>(
706 state_json: &'de str,
707 state_keys: &[&str],
708) -> Result<ProjectedRawState<'de>, HandlerError> {
709 let is_object = state_json
710 .as_bytes()
711 .iter()
712 .find(|byte| !byte.is_ascii_whitespace())
713 .is_some_and(|byte| *byte == b'{');
714 if !is_object {
715 validate_json(state_json)?;
716 return Ok(ProjectedRawState {
717 entries: Vec::new(),
718 });
719 }
720
721 let mut deserializer = serde_json::Deserializer::from_str(state_json);
722 let mut state = ProjectedRawStateSeed { state_keys }
723 .deserialize(&mut deserializer)
724 .and_then(|state| {
725 deserializer.end()?;
726 Ok(state)
727 })
728 .map_err(|error| invalid_state_json(&error.to_string()))?;
729 state.entries.sort_unstable_by(|left, right| {
730 left.0.as_ref().as_bytes().cmp(right.0.as_ref().as_bytes())
731 });
732 Ok(state)
733}
734
735fn select_raw_state<'de>(
736 state_json: &'de str,
737 selection: &StateSelection<'_>,
738) -> Result<SelectedRawState<'de>, HandlerError> {
739 let state_keys = match selection {
740 StateSelection::Full => {
741 return serde_json::from_str::<&RawValue>(state_json)
742 .map(SelectedRawState::Full)
743 .map_err(|error| invalid_state_json(&error.to_string()));
744 }
745 StateSelection::Keys(keys) => keys.as_slice(),
746 StateSelection::BorrowedKeys(keys) => *keys,
747 };
748 project_raw_state(state_json, state_keys).map(SelectedRawState::Keys)
749}
750
751struct ProjectedRawStateSeed<'a> {
752 state_keys: &'a [&'a str],
753}
754
755impl<'de> DeserializeSeed<'de> for ProjectedRawStateSeed<'_> {
756 type Value = ProjectedRawState<'de>;
757
758 fn deserialize<D>(self, deserializer: D) -> std::result::Result<Self::Value, D::Error>
759 where
760 D: serde::Deserializer<'de>,
761 {
762 deserializer.deserialize_map(ProjectedRawStateVisitor {
763 state_keys: self.state_keys,
764 })
765 }
766}
767
768struct ProjectedRawStateVisitor<'a> {
769 state_keys: &'a [&'a str],
770}
771
772impl<'de> Visitor<'de> for ProjectedRawStateVisitor<'_> {
773 type Value = ProjectedRawState<'de>;
774
775 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
776 formatter.write_str("a JSON object")
777 }
778
779 fn visit_map<A>(self, mut map: A) -> std::result::Result<Self::Value, A::Error>
780 where
781 A: MapAccess<'de>,
782 {
783 let mut entries: Vec<(Cow<'de, str>, &'de RawValue)> =
784 Vec::with_capacity(self.state_keys.len());
785 while let Some(key) = map.next_key_seed(BorrowedString)? {
786 if self.state_keys.binary_search(&key.as_ref()).is_err() {
787 map.next_value_seed(ValidJson)?;
788 continue;
789 }
790
791 let value: &'de RawValue = map.next_value()?;
792 validate_json_inner(value.get()).map_err(serde::de::Error::custom)?;
793 if let Some(entry) = entries
794 .iter_mut()
795 .find(|(existing, _)| existing.as_ref() == key.as_ref())
796 {
797 *entry = (key, value);
798 } else {
799 entries.push((key, value));
800 }
801 }
802 Ok(ProjectedRawState { entries })
803 }
804}
805
806struct BorrowedString;
807
808impl<'de> DeserializeSeed<'de> for BorrowedString {
809 type Value = Cow<'de, str>;
810
811 fn deserialize<D>(self, deserializer: D) -> std::result::Result<Self::Value, D::Error>
812 where
813 D: serde::Deserializer<'de>,
814 {
815 deserializer.deserialize_str(BorrowedStringVisitor)
816 }
817}
818
819struct BorrowedStringVisitor;
820
821impl<'de> Visitor<'de> for BorrowedStringVisitor {
822 type Value = Cow<'de, str>;
823
824 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
825 formatter.write_str("a string")
826 }
827
828 fn visit_borrowed_str<E>(self, value: &'de str) -> std::result::Result<Self::Value, E> {
829 Ok(Cow::Borrowed(value))
830 }
831
832 fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E> {
833 Ok(Cow::Owned(value.to_owned()))
834 }
835
836 fn visit_string<E>(self, value: String) -> std::result::Result<Self::Value, E> {
837 Ok(Cow::Owned(value))
838 }
839}
840
841#[cold]
842#[inline(never)]
843fn invalid_state_json(message: &str) -> HandlerError {
844 HandlerError::Rendering(format!("invalid state JSON: {message}"))
845}
846
847#[cold]
848#[inline(never)]
849fn partial_serialize_error(message: &str) -> HandlerError {
850 HandlerError::Rendering(format!("failed to serialize partial response: {message}"))
851}
852
853#[cold]
854#[inline(never)]
855fn partial_response_not_object() -> HandlerError {
856 HandlerError::Invariant("partial response must serialize as a JSON object".to_string())
857}
858
859pub(crate) fn collect_reachable_components_for_request(
861 protocol: &WebUIProtocol,
862 entry_id: &str,
863 request_path: &str,
864 route_index: &CompiledRouteIndex,
865) -> HashSet<String> {
866 collect_inventoryable_components(protocol, entry_id, Some(request_path), false, route_index)
869 .into_iter()
870 .collect()
871}
872
873pub fn filter_needed_components(
883 component_names: &[String],
884 inventory_hex: &str,
885 index: &HashMap<String, u32>,
886) -> Result<(Vec<String>, String), HandlerError> {
887 let client_inv = parse_inventory(inventory_hex)?;
888 let mut updated_inv = client_inv.clone();
889
890 let mut needed = Vec::with_capacity(component_names.len());
891 for name in component_names {
892 if let Some(&idx) = index.get(name.as_str()) {
893 if !has_component(&client_inv, idx) {
894 needed.push(name.clone());
895 }
896 set_component(&mut updated_inv, idx);
897 } else {
898 needed.push(name.clone());
902 }
903 }
904
905 Ok((needed, encode_inventory(&updated_inv)))
906}
907
908fn has_template_payload(component: &webui_protocol::ComponentData) -> bool {
909 !component.template_json.is_empty() || !component.template.is_empty()
910}
911
912#[derive(Debug)]
913struct QueuedFragment {
914 id: String,
915 inventoryable: bool,
916 route_base: String,
918}
919
920#[inline]
921fn mark_fragment_visited(
922 visited: &mut HashSet<(String, String)>,
923 queued: &QueuedFragment,
924 route_sensitive: bool,
925) -> bool {
926 let route_base = if route_sensitive {
927 queued.route_base.clone()
928 } else {
929 String::new()
930 };
931 visited.insert((queued.id.clone(), route_base))
932}
933
934struct ChildWalkCtx<'a> {
937 request_path: &'a str,
938 protocol: &'a WebUIProtocol,
939 route_index: &'a CompiledRouteIndex,
940}
941
942fn collect_inventoryable_components(
953 protocol: &WebUIProtocol,
954 entry_id: &str,
955 request_path: Option<&str>,
956 root_inventoryable: bool,
957 route_index: &CompiledRouteIndex,
958) -> Vec<String> {
959 let stack = vec![QueuedFragment {
960 id: entry_id.to_string(),
961 inventoryable: root_inventoryable,
962 route_base: "/".to_string(),
963 }];
964 collect_inventoryable_components_from_stack(protocol, request_path, route_index, stack)
965}
966
967pub(crate) fn collect_reachable_components_from_roots(
975 protocol: &WebUIProtocol,
976 roots: &[(u32, Option<Box<str>>)],
977 reachability: &ComponentReachabilityIndex,
978 request_path: &str,
979 route_index: &CompiledRouteIndex,
980) -> Vec<String> {
981 let mut stack = Vec::with_capacity(roots.len());
982 for (root, route_base) in roots.iter().rev() {
983 let Some(name) = reachability.name(*root) else {
984 continue;
985 };
986 stack.push(QueuedFragment {
987 id: name.to_string(),
988 inventoryable: true,
989 route_base: route_base.as_deref().unwrap_or("/").to_string(),
990 });
991 }
992 collect_inventoryable_components_from_stack(protocol, Some(request_path), route_index, stack)
993}
994
995fn collect_inventoryable_components_from_stack(
996 protocol: &WebUIProtocol,
997 request_path: Option<&str>,
998 route_index: &CompiledRouteIndex,
999 mut stack: Vec<QueuedFragment>,
1000) -> Vec<String> {
1001 let mut visited_fragments = HashSet::new();
1002 let route_sensitive = request_path.is_some();
1003 let mut seen_components = HashSet::new();
1008 let mut component_ids: Vec<String> = Vec::new();
1009 while let Some(queued) = stack.pop() {
1010 if queued.id.is_empty() {
1011 continue;
1012 }
1013
1014 if queued.inventoryable && seen_components.insert(queued.id.clone()) {
1015 component_ids.push(queued.id.clone());
1016 }
1017
1018 if !mark_fragment_visited(&mut visited_fragments, &queued, route_sensitive) {
1019 continue;
1020 }
1021
1022 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
1023 continue;
1024 };
1025
1026 let matched_route = request_path.and_then(|path| {
1027 route_renderer::find_best_route_match(
1028 &frag_list.fragments,
1029 path,
1030 &queued.route_base,
1031 route_index,
1032 )
1033 });
1034
1035 for frag in frag_list.fragments.iter().rev() {
1038 match frag.fragment.as_ref() {
1039 Some(Fragment::Component(component)) => {
1040 stack.push(QueuedFragment {
1041 id: component.fragment_id.clone(),
1042 inventoryable: true,
1043 route_base: queued.route_base.clone(),
1044 });
1045 }
1046 Some(Fragment::ForLoop(for_loop)) => {
1047 stack.push(QueuedFragment {
1048 id: for_loop.fragment_id.clone(),
1049 inventoryable: false,
1050 route_base: queued.route_base.clone(),
1051 });
1052 }
1053 Some(Fragment::IfCond(if_cond)) => {
1054 stack.push(QueuedFragment {
1055 id: if_cond.fragment_id.clone(),
1056 inventoryable: false,
1057 route_base: queued.route_base.clone(),
1058 });
1059 }
1060 Some(Fragment::Attribute(attr)) if !attr.template.is_empty() => {
1061 stack.push(QueuedFragment {
1062 id: attr.template.clone(),
1063 inventoryable: false,
1064 route_base: queued.route_base.clone(),
1065 });
1066 }
1067 Some(Fragment::Route(route_frag)) => {
1068 let is_selected = matched_route
1069 .as_ref()
1070 .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
1071 if is_selected && !route_frag.fragment_id.is_empty() {
1072 let child_route_base = if let Some((_, ref rm)) = matched_route {
1074 if let Some(path) = request_path {
1075 route_matcher::compute_route_base(path, rm.consumed_segments)
1076 } else {
1077 queued.route_base.clone()
1078 }
1079 } else {
1080 queued.route_base.clone()
1081 };
1082
1083 stack.push(QueuedFragment {
1084 id: route_frag.fragment_id.clone(),
1085 inventoryable: protocol
1086 .components
1087 .get(&route_frag.fragment_id)
1088 .is_some_and(has_template_payload),
1089 route_base: child_route_base.clone(),
1090 });
1091
1092 collect_route_boundary_components(
1100 std::slice::from_ref(route_frag),
1101 &child_route_base,
1102 protocol,
1103 &mut stack,
1104 );
1105
1106 if !route_frag.children.is_empty() {
1110 if let Some(path) = request_path {
1111 walk_route_children(
1112 &route_frag.children,
1113 &child_route_base,
1114 &mut stack,
1115 &mut ChildWalkCtx {
1116 request_path: path,
1117 protocol,
1118 route_index,
1119 },
1120 );
1121 }
1122 }
1123 }
1124 }
1125 _ => {}
1126 }
1127 }
1128 }
1129
1130 component_ids
1131}
1132
1133fn select_best_child_route(
1138 children: &[WebUIFragmentRoute],
1139 request_path: &str,
1140 route_base: &str,
1141 route_index: &CompiledRouteIndex,
1142) -> Option<(usize, route_matcher::RouteMatch)> {
1143 let request_segments = route_matcher::split_request_path(request_path);
1144 let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
1145 for (idx, child) in children.iter().enumerate() {
1146 if let Some(m) = route_matcher::match_route_indexed_with_segments(
1147 route_index,
1148 &child.path,
1149 route_base,
1150 &request_segments,
1151 child.exact,
1152 ) {
1153 let is_better = best
1154 .as_ref()
1155 .is_none_or(|(_, prev)| m.specificity > prev.specificity);
1156 if is_better {
1157 best = Some((idx, m));
1158 }
1159 }
1160 }
1161 best
1162}
1163
1164fn walk_route_children(
1167 children: &[WebUIFragmentRoute],
1168 route_base: &str,
1169 stack: &mut Vec<QueuedFragment>,
1170 ctx: &mut ChildWalkCtx<'_>,
1171) {
1172 let mut current = children;
1173 let mut base = route_base.to_string();
1174
1175 loop {
1176 let Some((idx, ref rm)) =
1177 select_best_child_route(current, ctx.request_path, &base, ctx.route_index)
1178 else {
1179 break;
1180 };
1181 let matched = ¤t[idx];
1182 if matched.fragment_id.is_empty() {
1183 break;
1184 }
1185
1186 let child_base = route_matcher::compute_route_base(ctx.request_path, rm.consumed_segments);
1187
1188 stack.push(QueuedFragment {
1189 id: matched.fragment_id.clone(),
1190 inventoryable: ctx
1191 .protocol
1192 .components
1193 .get(&matched.fragment_id)
1194 .is_some_and(has_template_payload),
1195 route_base: child_base.clone(),
1196 });
1197
1198 if !matched.pending_component.is_empty() {
1199 stack.push(QueuedFragment {
1200 id: matched.pending_component.clone(),
1201 inventoryable: ctx
1202 .protocol
1203 .components
1204 .get(&matched.pending_component)
1205 .is_some_and(has_template_payload),
1206 route_base: child_base.clone(),
1207 });
1208 }
1209 if !matched.error_component.is_empty() {
1210 stack.push(QueuedFragment {
1211 id: matched.error_component.clone(),
1212 inventoryable: ctx
1213 .protocol
1214 .components
1215 .get(&matched.error_component)
1216 .is_some_and(has_template_payload),
1217 route_base: child_base.clone(),
1218 });
1219 }
1220
1221 if matched.children.is_empty() {
1222 break;
1223 }
1224 current = &matched.children;
1225 base = child_base;
1226 }
1227}
1228
1229fn collect_route_boundary_components(
1238 routes: &[WebUIFragmentRoute],
1239 route_base: &str,
1240 protocol: &WebUIProtocol,
1241 stack: &mut Vec<QueuedFragment>,
1242) {
1243 let mut remaining: Vec<&WebUIFragmentRoute> = routes.iter().collect();
1244 while let Some(route) = remaining.pop() {
1245 for component in [&route.pending_component, &route.error_component] {
1246 if component.is_empty() {
1247 continue;
1248 }
1249 stack.push(QueuedFragment {
1250 id: component.clone(),
1251 inventoryable: protocol
1252 .components
1253 .get(component)
1254 .is_some_and(has_template_payload),
1255 route_base: route_base.to_string(),
1256 });
1257 }
1258 remaining.extend(route.children.iter());
1259 }
1260}
1261
1262pub fn collect_nested_route_params(
1267 protocol: &Protocol,
1268 entry_id: &str,
1269 request_path: &str,
1270) -> HashMap<String, String> {
1271 let route_index = protocol.route_index();
1272 let protocol = protocol.protocol();
1273 let mut all_params = HashMap::new();
1274 let mut visited_fragments = HashSet::new();
1275 let mut stack = vec![QueuedFragment {
1276 id: entry_id.to_string(),
1277 inventoryable: false,
1278 route_base: "/".to_string(),
1279 }];
1280
1281 while let Some(queued) = stack.pop() {
1282 if queued.id.is_empty() || !mark_fragment_visited(&mut visited_fragments, &queued, true) {
1283 continue;
1284 }
1285
1286 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
1287 continue;
1288 };
1289
1290 let matched_route = route_renderer::find_best_route_match(
1291 &frag_list.fragments,
1292 request_path,
1293 &queued.route_base,
1294 route_index,
1295 );
1296
1297 for frag in &frag_list.fragments {
1298 match frag.fragment.as_ref() {
1299 Some(Fragment::Component(component)) => {
1300 stack.push(QueuedFragment {
1301 id: component.fragment_id.clone(),
1302 inventoryable: false,
1303 route_base: queued.route_base.clone(),
1304 });
1305 }
1306 Some(Fragment::Route(route_frag)) => {
1307 let is_selected = matched_route
1308 .as_ref()
1309 .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
1310 if is_selected && !route_frag.fragment_id.is_empty() {
1311 if let Some((_, ref rm)) = matched_route {
1312 all_params.extend(rm.params.clone());
1314
1315 let child_route_base = route_matcher::compute_route_base(
1316 request_path,
1317 rm.consumed_segments,
1318 );
1319
1320 stack.push(QueuedFragment {
1321 id: route_frag.fragment_id.clone(),
1322 inventoryable: false,
1323 route_base: child_route_base.clone(),
1324 });
1325
1326 collect_params_from_children(
1328 &route_frag.children,
1329 request_path,
1330 &child_route_base,
1331 &mut all_params,
1332 route_index,
1333 );
1334 }
1335 }
1336 }
1337 _ => {}
1338 }
1339 }
1340 }
1341
1342 all_params
1343}
1344
1345fn collect_params_from_children(
1347 children: &[WebUIFragmentRoute],
1348 request_path: &str,
1349 route_base: &str,
1350 all_params: &mut HashMap<String, String>,
1351 route_index: &CompiledRouteIndex,
1352) {
1353 let mut current = children;
1354 let mut base = route_base.to_string();
1355
1356 loop {
1357 let Some((idx, rm)) = select_best_child_route(current, request_path, &base, route_index)
1358 else {
1359 break;
1360 };
1361 all_params.extend(rm.params);
1362 let matched = ¤t[idx];
1363 if matched.children.is_empty() {
1364 break;
1365 }
1366 let child_base = route_matcher::compute_route_base(request_path, rm.consumed_segments);
1367 current = &matched.children;
1368 base = child_base;
1369 }
1370}
1371
1372fn resolve_tag_templates(templates: &[String], params: &HashMap<String, String>) -> Vec<String> {
1380 let mut resolved = Vec::with_capacity(templates.len());
1381 for template in templates {
1382 if !template.contains('{') {
1383 resolved.push(template.clone());
1384 continue;
1385 }
1386 let mut result = String::with_capacity(template.len());
1387 let mut rest = template.as_str();
1388 while let Some(open) = rest.find('{') {
1389 result.push_str(&rest[..open]);
1390 rest = &rest[open + 1..];
1391 if let Some(close) = rest.find('}') {
1392 let name = &rest[..close];
1393 if let Some(value) = params.get(name) {
1394 result.push_str(value);
1395 } else {
1396 result.push('{');
1397 result.push_str(name);
1398 result.push('}');
1399 }
1400 rest = &rest[close + 1..];
1401 } else {
1402 result.push('{');
1403 }
1404 }
1405 result.push_str(rest);
1406 resolved.push(result);
1407 }
1408 resolved
1409}
1410
1411fn collect_inventory_and_chain(
1415 protocol: &WebUIProtocol,
1416 entry_id: &str,
1417 request_path: &str,
1418 index: &mut RequestProtocolIndex<'_>,
1419) -> (Vec<String>, Vec<RouteChainEntry>) {
1420 let mut visited_fragments = HashSet::new();
1421 let mut seen_components = HashSet::new();
1426 let mut component_ids: Vec<String> = Vec::new();
1427 let mut chain = Vec::new();
1428 let mut stack = vec![QueuedFragment {
1429 id: entry_id.to_string(),
1430 inventoryable: false,
1431 route_base: "/".to_string(),
1432 }];
1433
1434 while let Some(queued) = stack.pop() {
1435 if queued.id.is_empty() {
1436 continue;
1437 }
1438
1439 if queued.inventoryable && seen_components.insert(queued.id.clone()) {
1440 component_ids.push(queued.id.clone());
1441 }
1442
1443 if !mark_fragment_visited(&mut visited_fragments, &queued, true) {
1444 continue;
1445 }
1446
1447 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
1448 continue;
1449 };
1450
1451 let matched_route = route_renderer::find_best_route_match(
1452 &frag_list.fragments,
1453 request_path,
1454 &queued.route_base,
1455 index.route_index,
1456 );
1457
1458 for frag in &frag_list.fragments {
1459 match frag.fragment.as_ref() {
1460 Some(Fragment::Component(component)) => {
1461 stack.push(QueuedFragment {
1463 id: component.fragment_id.clone(),
1464 inventoryable: true,
1465 route_base: queued.route_base.clone(),
1466 });
1467 }
1468 Some(Fragment::ForLoop(for_loop)) => {
1469 stack.push(QueuedFragment {
1471 id: for_loop.fragment_id.clone(),
1472 inventoryable: false,
1473 route_base: queued.route_base.clone(),
1474 });
1475 }
1476 Some(Fragment::IfCond(if_cond)) => {
1477 stack.push(QueuedFragment {
1478 id: if_cond.fragment_id.clone(),
1479 inventoryable: false,
1480 route_base: queued.route_base.clone(),
1481 });
1482 }
1483 Some(Fragment::Attribute(attr)) if !attr.template.is_empty() => {
1484 stack.push(QueuedFragment {
1485 id: attr.template.clone(),
1486 inventoryable: false,
1487 route_base: queued.route_base.clone(),
1488 });
1489 }
1490 Some(Fragment::Route(route_frag)) => {
1491 let is_selected = matched_route
1492 .as_ref()
1493 .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
1494 if is_selected && !route_frag.fragment_id.is_empty() {
1495 if let Some((_, ref rm)) = matched_route {
1496 chain.push(RouteChainEntry {
1498 component: route_frag.fragment_id.clone(),
1499 path: route_frag.path.clone(),
1500 params: rm.params.clone(),
1501 exact: route_frag.exact,
1502 allowed_query: route_frag.allowed_query.clone(),
1503 keep_alive: route_frag.keep_alive,
1504 cache_tags: route_frag.cache_tags.clone(),
1505 invalidates: route_frag.invalidates.clone(),
1506 pending_component: route_frag.pending_component.clone(),
1507 error_component: route_frag.error_component.clone(),
1508 });
1509
1510 let child_route_base = route_matcher::compute_route_base(
1511 request_path,
1512 rm.consumed_segments,
1513 );
1514
1515 let is_inventoryable = protocol
1517 .components
1518 .get(&route_frag.fragment_id)
1519 .is_some_and(has_template_payload);
1520 stack.push(QueuedFragment {
1521 id: route_frag.fragment_id.clone(),
1522 inventoryable: is_inventoryable,
1523 route_base: child_route_base.clone(),
1524 });
1525
1526 collect_route_boundary_components(
1527 std::slice::from_ref(route_frag),
1528 &child_route_base,
1529 protocol,
1530 &mut stack,
1531 );
1532
1533 if !route_frag.children.is_empty() {
1535 walk_children_for_inventory_and_chain(
1536 &route_frag.children,
1537 &child_route_base,
1538 &mut stack,
1539 &mut chain,
1540 &mut ChildWalkCtx {
1541 request_path,
1542 protocol,
1543 route_index: index.route_index,
1544 },
1545 );
1546 }
1547 }
1548 }
1549 }
1550 _ => {}
1551 }
1552 }
1553 }
1554
1555 (component_ids, chain)
1556}
1557
1558fn walk_children_for_inventory_and_chain(
1560 children: &[WebUIFragmentRoute],
1561 route_base: &str,
1562 stack: &mut Vec<QueuedFragment>,
1563 chain: &mut Vec<RouteChainEntry>,
1564 ctx: &mut ChildWalkCtx<'_>,
1565) {
1566 let mut current = children;
1567 let mut base = route_base.to_string();
1568
1569 loop {
1570 let Some((idx, ref rm)) =
1571 select_best_child_route(current, ctx.request_path, &base, ctx.route_index)
1572 else {
1573 break;
1574 };
1575 let matched = ¤t[idx];
1576 if matched.fragment_id.is_empty() {
1577 break;
1578 }
1579
1580 let child_base = route_matcher::compute_route_base(ctx.request_path, rm.consumed_segments);
1581
1582 chain.push(RouteChainEntry {
1583 component: matched.fragment_id.clone(),
1584 path: matched.path.clone(),
1585 params: rm.params.clone(),
1586 exact: matched.exact,
1587 allowed_query: matched.allowed_query.clone(),
1588 keep_alive: matched.keep_alive,
1589 cache_tags: matched.cache_tags.clone(),
1590 invalidates: matched.invalidates.clone(),
1591 pending_component: matched.pending_component.clone(),
1592 error_component: matched.error_component.clone(),
1593 });
1594
1595 stack.push(QueuedFragment {
1596 id: matched.fragment_id.clone(),
1597 inventoryable: ctx
1598 .protocol
1599 .components
1600 .get(&matched.fragment_id)
1601 .is_some_and(has_template_payload),
1602 route_base: child_base.clone(),
1603 });
1604
1605 if matched.children.is_empty() {
1606 break;
1607 }
1608 current = &matched.children;
1609 base = child_base;
1610 }
1611}
1612
1613#[cfg(test)]
1628fn render_partial_metadata(
1629 protocol: &WebUIProtocol,
1630 entry_id: &str,
1631 request_path: &str,
1632 inventory_hex: &str,
1633 index: &mut ProtocolIndex,
1634) -> Result<Value, HandlerError> {
1635 let mut request_index = index.request_index();
1636 render_partial_indexed(
1637 protocol,
1638 entry_id,
1639 request_path,
1640 inventory_hex,
1641 &mut request_index,
1642 )
1643}
1644
1645#[cfg(test)]
1652fn render_partial(
1653 protocol: &WebUIProtocol,
1654 state: Value,
1655 entry_id: &str,
1656 request_path: &str,
1657 inventory_hex: &str,
1658) -> Result<Value, HandlerError> {
1659 let mut index = ProtocolIndex::new(protocol);
1660 let mut request_index = index.request_index();
1661 let (mut response, state_selection) = render_partial_indexed_with_state(
1662 protocol,
1663 entry_id,
1664 request_path,
1665 inventory_hex,
1666 &mut request_index,
1667 )?;
1668 let response = response
1669 .as_object_mut()
1670 .ok_or_else(partial_response_not_object)?;
1671 response.insert("state".into(), select_owned_state(state, &state_selection));
1672 Ok(Value::Object(std::mem::take(response)))
1673}
1674
1675#[cfg(test)]
1676fn render_partial_indexed(
1677 protocol: &WebUIProtocol,
1678 entry_id: &str,
1679 request_path: &str,
1680 inventory_hex: &str,
1681 index: &mut RequestProtocolIndex<'_>,
1682) -> Result<Value, HandlerError> {
1683 render_partial_indexed_with_state(protocol, entry_id, request_path, inventory_hex, index)
1684 .map(|(response, _)| response)
1685}
1686
1687fn render_partial_indexed_with_state<'a>(
1688 protocol: &'a WebUIProtocol,
1689 entry_id: &str,
1690 request_path: &str,
1691 inventory_hex: &str,
1692 index: &mut RequestProtocolIndex<'_>,
1693) -> Result<(Value, StateSelection<'a>), HandlerError> {
1694 let (component_ids, mut chain) =
1696 collect_inventory_and_chain(protocol, entry_id, request_path, index);
1697 let state_selection =
1698 crate::collect_navigation_state(protocol, component_ids.iter().map(String::as_str));
1699
1700 let (needed_names, updated_inv) =
1701 filter_needed_components(&component_ids, inventory_hex, index.component_index)?;
1702
1703 let mut accumulated_params: HashMap<String, String> = HashMap::new();
1705 let mut all_resolved_tags: Vec<String> = Vec::new();
1706
1707 for entry in &mut chain {
1708 for (k, v) in &entry.params {
1709 accumulated_params.insert(k.clone(), v.clone());
1710 }
1711 entry.cache_tags = resolve_tag_templates(&entry.cache_tags, &accumulated_params);
1712 entry.invalidates = resolve_tag_templates(&entry.invalidates, &accumulated_params);
1713 all_resolved_tags.extend(entry.cache_tags.iter().cloned());
1714 }
1715
1716 let tag_refs: Vec<&str> = needed_names.iter().map(|s| s.as_str()).collect();
1717 let assets = collect_component_assets(protocol, &tag_refs, index)?;
1718
1719 let chain_array = Value::Array(chain.iter().map(RouteChainEntry::to_json).collect());
1720
1721 let mut result = serde_json::Map::with_capacity(7);
1722 result.insert("templateStyles".into(), Value::Array(assets.styles));
1723 result.insert("templates".into(), Value::Object(assets.templates));
1724 result.insert("templateFunctions".into(), Value::Object(assets.functions));
1725 result.insert("inventory".into(), Value::String(updated_inv));
1726 result.insert("path".into(), Value::String(request_path.to_string()));
1727 result.insert("chain".into(), chain_array);
1728 if !all_resolved_tags.is_empty() {
1729 let mut seen = HashSet::new();
1730 let deduped: Vec<Value> = all_resolved_tags
1731 .into_iter()
1732 .filter(|t| seen.insert(t.clone()))
1733 .map(Value::String)
1734 .collect();
1735 result.insert("cacheTags".into(), Value::Array(deduped));
1736 }
1737 Ok((Value::Object(result), state_selection))
1738}
1739
1740#[cfg(test)]
1741fn select_owned_state(state: Value, selection: &StateSelection<'_>) -> Value {
1742 let state_keys = match selection {
1743 StateSelection::Full => return state,
1744 StateSelection::Keys(keys) => keys.as_slice(),
1745 StateSelection::BorrowedKeys(keys) => *keys,
1746 };
1747 let Value::Object(mut source) = state else {
1748 return Value::Object(Map::new());
1749 };
1750 let mut projected = Map::with_capacity(state_keys.len().min(source.len()));
1751 for &key in state_keys {
1752 if let Some(value) = source.remove(key) {
1753 projected.insert(key.to_owned(), value);
1754 }
1755 }
1756 Value::Object(projected)
1757}
1758
1759#[cfg(test)]
1770fn render_action_response(
1771 protocol: &WebUIProtocol,
1772 state: Value,
1773 entry_id: &str,
1774 request_path: &str,
1775 index: &mut ProtocolIndex,
1776) -> Value {
1777 let mut request_index = index.request_index();
1778 render_action_response_indexed(protocol, state, entry_id, request_path, &mut request_index)
1779}
1780
1781#[cfg(test)]
1782fn render_action_response_indexed(
1783 protocol: &WebUIProtocol,
1784 state: Value,
1785 entry_id: &str,
1786 request_path: &str,
1787 index: &mut RequestProtocolIndex<'_>,
1788) -> Value {
1789 let chain = collect_route_chain(protocol, entry_id, request_path, index.route_index);
1790
1791 let mut accumulated_params: HashMap<String, String> = HashMap::new();
1793 let mut all_invalidates: Vec<String> = Vec::new();
1794
1795 for entry in &chain {
1796 for (k, v) in &entry.params {
1797 accumulated_params.insert(k.clone(), v.clone());
1798 }
1799 let resolved = resolve_tag_templates(&entry.invalidates, &accumulated_params);
1800 all_invalidates.extend(resolved);
1801 }
1802
1803 let mut seen = HashSet::new();
1805 let deduped: Vec<Value> = all_invalidates
1806 .into_iter()
1807 .filter(|t| seen.insert(t.clone()))
1808 .map(Value::String)
1809 .collect();
1810
1811 let mut result = serde_json::Map::with_capacity(3);
1812 result.insert("state".into(), state);
1813 result.insert("invalidateTags".into(), Value::Array(deduped));
1814 result.insert("path".into(), Value::String(request_path.to_string()));
1815 Value::Object(result)
1816}
1817
1818#[cfg(test)]
1827fn render_component_templates(
1828 protocol: &WebUIProtocol,
1829 component_tags: &[&str],
1830 inventory_hex: &str,
1831 index: &mut ProtocolIndex,
1832) -> Result<Value, HandlerError> {
1833 let mut request_index = index.request_index();
1834 render_component_templates_indexed(protocol, component_tags, inventory_hex, &mut request_index)
1835}
1836
1837fn render_component_templates_indexed(
1838 protocol: &WebUIProtocol,
1839 component_tags: &[&str],
1840 inventory_hex: &str,
1841 index: &mut RequestProtocolIndex<'_>,
1842) -> Result<Value, HandlerError> {
1843 let mut seen: HashSet<&str> = HashSet::new();
1845 let requested: Vec<String> = component_tags
1846 .iter()
1847 .filter(|s| seen.insert(**s))
1848 .map(|s| (*s).to_string())
1849 .collect();
1850 let (needed, updated_inv) =
1851 filter_needed_components(&requested, inventory_hex, index.component_index)?;
1852
1853 let tag_refs: Vec<&str> = needed.iter().map(|s| s.as_str()).collect();
1854 let assets = collect_component_assets(protocol, &tag_refs, index)?;
1855
1856 let mut result = serde_json::Map::with_capacity(4);
1857 result.insert("templateStyles".into(), Value::Array(assets.styles));
1858 result.insert("templates".into(), Value::Object(assets.templates));
1859 result.insert("templateFunctions".into(), Value::Object(assets.functions));
1860 result.insert("inventory".into(), Value::String(updated_inv));
1861 Ok(Value::Object(result))
1862}
1863
1864fn collect_component_assets(
1866 protocol: &WebUIProtocol,
1867 tags: &[&str],
1868 index: &mut RequestProtocolIndex<'_>,
1869) -> Result<ComponentAssets, HandlerError> {
1870 let mut style_array = Vec::new();
1871 let mut tmpl_map = serde_json::Map::new();
1872 let mut function_map = serde_json::Map::new();
1873
1874 let mut sorted_tags: Vec<&str> = tags.to_vec();
1876 sorted_tags.sort_unstable();
1877
1878 for tag in sorted_tags {
1879 let Some(component) = protocol.components.get(tag) else {
1880 continue;
1881 };
1882 if !has_template_payload(component) {
1883 continue;
1884 }
1885 if !component.css.is_empty() {
1886 let tag_html = crate::css_module::build_importmap_tag(tag, &component.css, None);
1890 style_array.push(Value::String(tag_html));
1891 }
1892 if !component.template_json.is_empty() {
1893 let template_value = cached_template_metadata(
1894 &mut index.template_metadata_cache,
1895 tag,
1896 &component.template_json,
1897 )?;
1898 tmpl_map.insert(tag.to_string(), template_value);
1899 if !component.template_functions.is_empty() {
1900 function_map.insert(
1901 tag.to_string(),
1902 Value::String(component.template_functions.clone()),
1903 );
1904 }
1905 } else {
1906 tmpl_map.insert(tag.to_string(), Value::String(component.template.clone()));
1907 }
1908 }
1909
1910 Ok(ComponentAssets {
1911 styles: style_array,
1912 templates: tmpl_map,
1913 functions: function_map,
1914 })
1915}
1916
1917fn cached_template_metadata(
1918 cache: &mut TemplateMetadataCache<'_>,
1919 tag: &str,
1920 template_json: &str,
1921) -> Result<Value, HandlerError> {
1922 match cache {
1923 #[cfg(test)]
1924 TemplateMetadataCache::Exclusive(cache) => {
1925 cached_template_metadata_exclusive(cache, tag, template_json)
1926 }
1927 TemplateMetadataCache::Shared(cache) => {
1928 if let Some(value) = cache
1929 .read()
1930 .map_err(|_| protocol_metadata_cache_poisoned())?
1931 .get(tag)
1932 .cloned()
1933 {
1934 return Ok(value);
1935 }
1936
1937 let value = parse_template_metadata(tag, template_json)?;
1938 let mut cache = cache
1939 .write()
1940 .map_err(|_| protocol_metadata_cache_poisoned())?;
1941 Ok(cache.entry(tag.to_string()).or_insert(value).clone())
1942 }
1943 }
1944}
1945
1946#[cfg(test)]
1947fn cached_template_metadata_exclusive(
1948 cache: &mut HashMap<String, Value>,
1949 tag: &str,
1950 template_json: &str,
1951) -> Result<Value, HandlerError> {
1952 if let Some(value) = cache.get(tag) {
1953 return Ok(value.clone());
1954 }
1955
1956 let value = parse_template_metadata(tag, template_json)?;
1957 cache.insert(tag.to_string(), value.clone());
1958 Ok(value)
1959}
1960
1961fn parse_template_metadata(tag: &str, template_json: &str) -> Result<Value, HandlerError> {
1962 serde_json::from_str(template_json).map_err(|error| {
1963 HandlerError::Rendering(format!(
1964 "failed to parse template metadata for {tag}: {error}"
1965 ))
1966 })
1967}
1968
1969#[cold]
1970#[inline(never)]
1971fn protocol_metadata_cache_poisoned() -> HandlerError {
1972 HandlerError::Invariant(
1973 "protocol metadata cache is unavailable after a previous panic".to_string(),
1974 )
1975}
1976
1977#[derive(Debug, Clone)]
1981pub struct RouteChainEntry {
1982 pub component: String,
1984 pub path: String,
1986 pub params: HashMap<String, String>,
1988 pub exact: bool,
1990 pub allowed_query: String,
1992 pub keep_alive: bool,
1994 pub cache_tags: Vec<String>,
1996 pub invalidates: Vec<String>,
1998 pub pending_component: String,
2000 pub error_component: String,
2002}
2003
2004impl RouteChainEntry {
2005 #[must_use]
2007 pub fn to_json(&self) -> Value {
2008 let mut obj = serde_json::Map::with_capacity(9);
2009 obj.insert("component".into(), Value::String(self.component.clone()));
2010 obj.insert("path".into(), Value::String(self.path.clone()));
2011 if !self.params.is_empty() {
2012 let params_obj: serde_json::Map<String, Value> = self
2013 .params
2014 .iter()
2015 .map(|(k, v)| (k.clone(), Value::String(v.clone())))
2016 .collect();
2017 obj.insert("params".into(), Value::Object(params_obj));
2018 }
2019 if self.exact {
2020 obj.insert("exact".into(), Value::Bool(true));
2021 }
2022 if !self.allowed_query.is_empty() {
2023 obj.insert(
2024 "allowedQuery".into(),
2025 Value::String(self.allowed_query.clone()),
2026 );
2027 }
2028 if self.keep_alive {
2029 obj.insert("keepAlive".into(), Value::Bool(true));
2030 }
2031 if !self.pending_component.is_empty() {
2032 obj.insert(
2033 "pendingComponent".into(),
2034 Value::String(self.pending_component.clone()),
2035 );
2036 }
2037 if !self.error_component.is_empty() {
2038 obj.insert(
2039 "errorComponent".into(),
2040 Value::String(self.error_component.clone()),
2041 );
2042 }
2043 if !self.invalidates.is_empty() {
2044 obj.insert(
2045 "invalidates".into(),
2046 Value::Array(
2047 self.invalidates
2048 .iter()
2049 .map(|s| Value::String(s.clone()))
2050 .collect(),
2051 ),
2052 );
2053 }
2054 Value::Object(obj)
2055 }
2056}
2057
2058pub(crate) fn collect_route_chain(
2063 protocol: &WebUIProtocol,
2064 entry_id: &str,
2065 request_path: &str,
2066 route_index: &CompiledRouteIndex,
2067) -> Vec<RouteChainEntry> {
2068 let mut chain = Vec::new();
2069 let mut visited_fragments = HashSet::new();
2070 let mut stack = vec![QueuedFragment {
2071 id: entry_id.to_string(),
2072 inventoryable: false,
2073 route_base: "/".to_string(),
2074 }];
2075
2076 while let Some(queued) = stack.pop() {
2077 if queued.id.is_empty() || !mark_fragment_visited(&mut visited_fragments, &queued, true) {
2078 continue;
2079 }
2080
2081 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
2082 continue;
2083 };
2084
2085 let matched_route = route_renderer::find_best_route_match(
2086 &frag_list.fragments,
2087 request_path,
2088 &queued.route_base,
2089 route_index,
2090 );
2091
2092 for frag in &frag_list.fragments {
2093 match frag.fragment.as_ref() {
2094 Some(Fragment::Component(component)) => {
2095 stack.push(QueuedFragment {
2096 id: component.fragment_id.clone(),
2097 inventoryable: false,
2098 route_base: queued.route_base.clone(),
2099 });
2100 }
2101 Some(Fragment::Route(route_frag)) => {
2102 let is_selected = matched_route
2103 .as_ref()
2104 .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
2105 if is_selected && !route_frag.fragment_id.is_empty() {
2106 if let Some((_, ref rm)) = matched_route {
2107 chain.push(RouteChainEntry {
2108 component: route_frag.fragment_id.clone(),
2109 path: route_frag.path.clone(),
2110 params: rm.params.clone(),
2111 exact: route_frag.exact,
2112 allowed_query: route_frag.allowed_query.clone(),
2113 keep_alive: route_frag.keep_alive,
2114 cache_tags: route_frag.cache_tags.clone(),
2115 invalidates: route_frag.invalidates.clone(),
2116 pending_component: route_frag.pending_component.clone(),
2117 error_component: route_frag.error_component.clone(),
2118 });
2119
2120 let child_route_base = route_matcher::compute_route_base(
2121 request_path,
2122 rm.consumed_segments,
2123 );
2124
2125 stack.push(QueuedFragment {
2126 id: route_frag.fragment_id.clone(),
2127 inventoryable: false,
2128 route_base: child_route_base.clone(),
2129 });
2130
2131 collect_chain_from_children(
2133 &route_frag.children,
2134 &child_route_base,
2135 &mut chain,
2136 &mut ChildWalkCtx {
2137 request_path,
2138 protocol,
2139 route_index,
2140 },
2141 );
2142 }
2143 }
2144 }
2145 _ => {}
2146 }
2147 }
2148 }
2149
2150 chain
2151}
2152
2153fn collect_chain_from_children(
2155 children: &[WebUIFragmentRoute],
2156 route_base: &str,
2157 chain: &mut Vec<RouteChainEntry>,
2158 ctx: &mut ChildWalkCtx<'_>,
2159) {
2160 let mut pending: Vec<(&[WebUIFragmentRoute], String)> =
2161 vec![(children, route_base.to_string())];
2162
2163 while let Some((current, base)) = pending.pop() {
2164 if let Some((idx, rm)) =
2165 select_best_child_route(current, ctx.request_path, &base, ctx.route_index)
2166 {
2167 let matched = ¤t[idx];
2168 chain.push(RouteChainEntry {
2169 component: matched.fragment_id.clone(),
2170 path: matched.path.clone(),
2171 params: rm.params,
2172 exact: matched.exact,
2173 allowed_query: matched.allowed_query.clone(),
2174 keep_alive: matched.keep_alive,
2175 cache_tags: matched.cache_tags.clone(),
2176 invalidates: matched.invalidates.clone(),
2177 pending_component: matched.pending_component.clone(),
2178 error_component: matched.error_component.clone(),
2179 });
2180 if !matched.children.is_empty() {
2181 let child_base =
2182 route_matcher::compute_route_base(ctx.request_path, rm.consumed_segments);
2183 pending.push((&matched.children, child_base));
2184 }
2185 }
2186 }
2187}
2188
2189#[cfg(test)]
2190mod tests {
2191 use super::*;
2192 use std::collections::HashMap;
2193 use std::sync::{Arc, Barrier};
2194 use std::thread;
2195 use webui_protocol::{FragmentList, WebUIFragment, WebUiFragmentRoute};
2196
2197 #[test]
2198 fn protocol_decodes_once_and_exposes_tokens() {
2199 let mut fragments = HashMap::new();
2200 fragments.insert(
2201 "index.html".to_string(),
2202 FragmentList {
2203 fragments: vec![WebUIFragment::raw("<p>Prepared</p>")],
2204 },
2205 );
2206 let protocol = WebUIProtocol::with_tokens(fragments, vec!["colorBrand".to_string()]);
2207 let bytes = protocol.to_protobuf().unwrap();
2208 let prepared = Protocol::from_protobuf(&bytes).unwrap();
2209
2210 assert!(prepared.protocol().fragments.contains_key("index.html"));
2211 assert_eq!(prepared.tokens(), ["colorBrand"]);
2212 }
2213
2214 #[test]
2215 fn protocol_partial_matches_direct_index_path() {
2216 let mut fragments = HashMap::new();
2217 fragments.insert(
2218 "index.html".to_string(),
2219 FragmentList {
2220 fragments: vec![WebUIFragment::route("/", "home-page")],
2221 },
2222 );
2223 fragments.insert(
2224 "home-page".to_string(),
2225 FragmentList {
2226 fragments: vec![WebUIFragment::raw("<p>Home</p>")],
2227 },
2228 );
2229 let protocol = WebUIProtocol::new(fragments);
2230 let mut direct_index = ProtocolIndex::new(&protocol);
2231 let direct =
2232 render_partial_metadata(&protocol, "index.html", "/", "", &mut direct_index).unwrap();
2233 let prepared = Protocol::new(protocol);
2234 let cached = prepared
2235 .render_partial_metadata("index.html", "/", "")
2236 .unwrap();
2237
2238 assert_eq!(cached, direct);
2239 }
2240
2241 #[test]
2242 fn protocol_partial_supports_concurrent_metadata_reads() {
2243 let mut fragments = HashMap::new();
2244 fragments.insert(
2245 "index.html".to_string(),
2246 FragmentList {
2247 fragments: vec![WebUIFragment::route("/", "home-page")],
2248 },
2249 );
2250 fragments.insert(
2251 "home-page".to_string(),
2252 FragmentList {
2253 fragments: vec![WebUIFragment::raw("<p>Home</p>")],
2254 },
2255 );
2256 let mut protocol = WebUIProtocol::new(fragments);
2257 protocol.components.insert(
2258 "home-page".to_string(),
2259 webui_protocol::ComponentData {
2260 template_json: r#"{"h":"<p>Home</p>"}"#.to_string(),
2261 ..Default::default()
2262 },
2263 );
2264
2265 let prepared = Arc::new(Protocol::new(protocol));
2266 let barrier = Arc::new(Barrier::new(4));
2267 let handles: Vec<_> = (0..4)
2268 .map(|_| {
2269 let prepared = Arc::clone(&prepared);
2270 let barrier = Arc::clone(&barrier);
2271 thread::spawn(move || {
2272 barrier.wait();
2273 for _ in 0..50 {
2274 let response = prepared
2275 .render_partial_metadata("index.html", "/", "")
2276 .unwrap();
2277 assert_eq!(response["templates"]["home-page"]["h"], "<p>Home</p>");
2278 }
2279 })
2280 })
2281 .collect();
2282
2283 for handle in handles {
2284 handle.join().unwrap();
2285 }
2286 }
2287
2288 fn prepared_partial_protocol(hydration_keys: &[&str]) -> Protocol {
2289 let mut fragments = HashMap::new();
2290 fragments.insert(
2291 "index.html".to_string(),
2292 FragmentList {
2293 fragments: vec![WebUIFragment::route("/", "home-page")],
2294 },
2295 );
2296 fragments.insert(
2297 "home-page".to_string(),
2298 FragmentList {
2299 fragments: vec![WebUIFragment::raw("<p>Home</p>")],
2300 },
2301 );
2302 let mut protocol = WebUIProtocol::new(fragments);
2303 protocol.components.insert(
2304 "home-page".to_string(),
2305 webui_protocol::ComponentData {
2306 template_json: r#"{"h":"<p>Home</p>"}"#.to_string(),
2307 hydration_mode: webui_protocol::StateProjectionMode::Keys as i32,
2308 hydration_keys: hydration_keys
2309 .iter()
2310 .map(|key| (*key).to_string())
2311 .collect(),
2312 navigation_mode: Some(webui_protocol::StateProjectionMode::Keys as i32),
2313 navigation_keys: hydration_keys
2314 .iter()
2315 .map(|key| (*key).to_string())
2316 .collect(),
2317 ..Default::default()
2318 },
2319 );
2320 Protocol::new(protocol)
2321 }
2322
2323 fn prepared_full_state_partial_protocol() -> Protocol {
2324 let mut prepared = prepared_partial_protocol(&[]);
2325 let protocol = &mut prepared.protocol;
2326 if let Some(component) = protocol.components.get_mut("home-page") {
2327 component.navigation_mode = Some(webui_protocol::StateProjectionMode::All as i32);
2328 component.navigation_keys.clear();
2329 }
2330 prepared
2331 }
2332
2333 #[test]
2334 fn partial_state_serialization_preserves_validated_raw_json() {
2335 let prepared = prepared_partial_protocol(&["value"]);
2336 let output = prepared
2337 .render_partial(
2338 r#"{"serverOnly":"drop","value":1e2}"#,
2339 "index.html",
2340 "/",
2341 "",
2342 )
2343 .unwrap();
2344 assert!(output.contains(r#""state":{"value":1e2}"#));
2345 assert!(!output.contains("serverOnly"));
2346
2347 let parsed: Value = serde_json::from_str(&output).unwrap();
2348 assert_eq!(parsed["state"]["value"], 100.0);
2349 }
2350
2351 #[test]
2352 fn uncertain_partial_surface_preserves_complete_raw_state() {
2353 let prepared = prepared_full_state_partial_protocol();
2354 let output = prepared
2355 .render_partial(
2356 r#"{"serverOnly":"keep","value":1e2}"#,
2357 "index.html",
2358 "/",
2359 "",
2360 )
2361 .unwrap();
2362 assert!(output.contains(r#""state":{"serverOnly":"keep","value":1e2}"#));
2363 }
2364
2365 #[test]
2366 fn partial_state_serialization_rejects_invalid_json() {
2367 let prepared = prepared_partial_protocol(&[]);
2368 let error = prepared
2369 .render_partial(r#"{"broken":"#, "index.html", "/", "")
2370 .expect_err("invalid state JSON must fail");
2371 assert!(error.to_string().contains("invalid state JSON"));
2372 }
2373
2374 #[test]
2375 fn partial_state_serialization_emits_empty_state_without_client_components() {
2376 let prepared = Protocol::new(WebUIProtocol::default());
2377 let output = prepared
2378 .render_partial(r#"{"serverOnly":"drop"}"#, "index.html", "/", "")
2379 .unwrap();
2380 let parsed: Value = serde_json::from_str(&output).unwrap();
2381 assert_eq!(parsed["state"], serde_json::json!({}));
2382 }
2383
2384 #[test]
2385 fn partial_state_serialization_validates_selected_and_skipped_numbers() {
2386 let prepared = prepared_partial_protocol(&["value"]);
2387 for state in [r#"{"value":1e9999}"#, r#"{"serverOnly":1e9999,"value":1}"#] {
2388 let error = prepared
2389 .render_partial(state, "index.html", "/", "")
2390 .expect_err("out-of-range state numbers must fail");
2391 assert!(error.to_string().contains("invalid state JSON"));
2392 }
2393 }
2394
2395 #[test]
2396 fn partial_state_serialization_uses_last_duplicate_and_decodes_keys() {
2397 let prepared = prepared_partial_protocol(&["value"]);
2398 let output = prepared
2399 .render_partial(r#"{"value":1,"va\u006cue":2}"#, "index.html", "/", "")
2400 .unwrap();
2401 let parsed: Value = serde_json::from_str(&output).unwrap();
2402 assert_eq!(parsed["state"], serde_json::json!({"value": 2}));
2403 }
2404
2405 #[test]
2406 fn parsed_partial_state_projects_without_cloning_server_only_values() {
2407 let prepared = prepared_partial_protocol(&["value"]);
2408 let response = render_partial(
2409 prepared.protocol(),
2410 serde_json::json!({"serverOnly": [1, 2, 3], "value": "kept"}),
2411 "index.html",
2412 "/",
2413 "",
2414 )
2415 .unwrap();
2416 assert_eq!(response["state"], serde_json::json!({"value": "kept"}));
2417 }
2418
2419 #[test]
2420 fn uncertain_partial_surface_preserves_complete_parsed_state() {
2421 let prepared = prepared_full_state_partial_protocol();
2422 let state = serde_json::json!({"serverOnly": [1, 2, 3], "value": "kept"});
2423 let response =
2424 render_partial(prepared.protocol(), state.clone(), "index.html", "/", "").unwrap();
2425 assert_eq!(response["state"], state);
2426 }
2427
2428 #[test]
2429 fn partial_state_validation_matches_value_number_limits() {
2430 for json in [
2431 r#"{"value":1e9999}"#,
2432 r#"{"value":-1e9999}"#,
2433 r#"{"value":1e308}"#,
2434 r#"{"value":18446744073709551615}"#,
2435 r#"{"value":18446744073709551616}"#,
2436 ] {
2437 assert_eq!(
2438 validate_json(json).is_ok(),
2439 serde_json::from_str::<Value>(json).is_ok(),
2440 "validation mismatch for {json}"
2441 );
2442 }
2443 }
2444
2445 #[test]
2446 fn test_parse_encode_inventory_roundtrip() {
2447 let original = vec![0xABu8, 0xCD, 0xEF, 0x01];
2448 let hex = encode_inventory(&original);
2449 let decoded = parse_inventory(&hex).unwrap();
2450 assert_eq!(original, decoded);
2451 }
2452
2453 #[test]
2454 fn test_parse_inventory_empty() {
2455 let result = parse_inventory("").unwrap();
2456 assert!(result.is_empty());
2457 }
2458
2459 #[test]
2460 fn test_parse_inventory_rejects_odd_length() {
2461 let result = parse_inventory("abc");
2462 assert!(result.is_err());
2463 }
2464
2465 #[test]
2466 fn test_parse_inventory_rejects_invalid_hex() {
2467 let result = parse_inventory("zz");
2468 assert!(result.is_err());
2469 }
2470
2471 #[test]
2472 fn test_bitfield_set_and_check() {
2473 let mut inv = vec![0u8; 4];
2474 set_component(&mut inv, 0);
2475 set_component(&mut inv, 7);
2476 set_component(&mut inv, 8);
2477 set_component(&mut inv, 15);
2478 assert!(has_component(&inv, 0));
2479 assert!(has_component(&inv, 7));
2480 assert!(has_component(&inv, 8));
2481 assert!(has_component(&inv, 15));
2482 assert!(!has_component(&inv, 1));
2483 assert!(!has_component(&inv, 9));
2484 }
2485
2486 #[test]
2487 fn test_filter_needed_components_no_false_positives() {
2488 let mut index = HashMap::new();
2490 index.insert("email-message".to_string(), 0);
2491 index.insert("o-button".to_string(), 1);
2492 index.insert("o-avatar".to_string(), 2);
2493
2494 let names = vec!["email-message".to_string(), "o-button".to_string()];
2495
2496 let mut inv = vec![0u8; 1];
2498 set_component(&mut inv, 1); let inv_hex = encode_inventory(&inv);
2500
2501 let (needed, _) = filter_needed_components(&names, &inv_hex, &index).unwrap();
2502 assert_eq!(needed.len(), 1);
2503 assert!(
2504 needed.contains(&"email-message".to_string()),
2505 "email-message must be needed: {needed:?}"
2506 );
2507 }
2508
2509 #[test]
2510 fn test_get_needed_components_empty_inventory() {
2511 let mut fragments = HashMap::new();
2512 fragments.insert(
2513 "app-shell".to_string(),
2514 FragmentList {
2515 fragments: vec![WebUIFragment::component("my-card")],
2516 },
2517 );
2518 fragments.insert(
2519 "my-card".to_string(),
2520 FragmentList {
2521 fragments: vec![WebUIFragment::raw("card")],
2522 },
2523 );
2524
2525 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2526 let comp_index = build_component_index(&protocol);
2527 let (needed, _inv) =
2528 get_needed_components(&protocol, "app-shell", "", &comp_index).unwrap();
2529 assert!(needed.contains(&"app-shell".to_string()));
2530 assert!(needed.contains(&"my-card".to_string()));
2531 }
2532
2533 #[test]
2534 fn test_get_needed_components_skips_inventory() {
2535 let mut fragments = HashMap::new();
2536 fragments.insert(
2537 "app-shell".to_string(),
2538 FragmentList {
2539 fragments: vec![WebUIFragment::component("my-card")],
2540 },
2541 );
2542 fragments.insert(
2543 "my-card".to_string(),
2544 FragmentList {
2545 fragments: vec![WebUIFragment::raw("card")],
2546 },
2547 );
2548
2549 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2550 protocol
2551 .components
2552 .entry("app-shell".to_string())
2553 .or_default()
2554 .template = "<t></t>".to_string();
2555 protocol
2556 .components
2557 .entry("my-card".to_string())
2558 .or_default()
2559 .template = "<t></t>".to_string();
2560
2561 let comp_index = build_component_index(&protocol);
2562 let (_needed, inv_hex) =
2563 get_needed_components(&protocol, "app-shell", "", &comp_index).unwrap();
2564 let (needed2, _) =
2565 get_needed_components(&protocol, "app-shell", &inv_hex, &comp_index).unwrap();
2566 assert!(needed2.is_empty());
2567 }
2568
2569 #[test]
2570 fn test_get_needed_components_skips_control_fragments() {
2571 let mut fragments = HashMap::new();
2572 fragments.insert(
2573 "app-shell".to_string(),
2574 FragmentList {
2575 fragments: vec![
2576 WebUIFragment::if_cond(
2577 webui_protocol::ConditionExpr::identifier("showNav"),
2578 "if-shell",
2579 ),
2580 WebUIFragment::for_loop("item", "items", "for-items"),
2581 WebUIFragment::attribute_template("title", "attr-title"),
2582 ],
2583 },
2584 );
2585 fragments.insert(
2586 "if-shell".to_string(),
2587 FragmentList {
2588 fragments: vec![WebUIFragment::component("mp-category-nav")],
2589 },
2590 );
2591 fragments.insert(
2592 "for-items".to_string(),
2593 FragmentList {
2594 fragments: vec![WebUIFragment::component("mp-product-card")],
2595 },
2596 );
2597 fragments.insert(
2598 "attr-title".to_string(),
2599 FragmentList {
2600 fragments: vec![WebUIFragment::raw("Products")],
2601 },
2602 );
2603 fragments.insert(
2604 "mp-category-nav".to_string(),
2605 FragmentList {
2606 fragments: vec![WebUIFragment::raw("<nav></nav>")],
2607 },
2608 );
2609 fragments.insert(
2610 "mp-product-card".to_string(),
2611 FragmentList {
2612 fragments: vec![WebUIFragment::raw("<article></article>")],
2613 },
2614 );
2615
2616 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2617 let comp_index = build_component_index(&protocol);
2618 let needed: HashSet<String> =
2619 get_needed_components(&protocol, "app-shell", "", &comp_index)
2620 .unwrap()
2621 .0
2622 .into_iter()
2623 .collect();
2624
2625 assert!(needed.contains("app-shell"));
2626 assert!(needed.contains("mp-category-nav"));
2627 assert!(needed.contains("mp-product-card"));
2628 assert!(!needed.contains("if-shell"));
2629 assert!(!needed.contains("for-items"));
2630 assert!(!needed.contains("attr-title"));
2631 }
2632
2633 #[test]
2634 fn test_get_needed_components_for_request_includes_shell_and_matched_route_chain() {
2635 let mut fragments = HashMap::new();
2636 fragments.insert(
2637 "index.html".to_string(),
2638 FragmentList {
2639 fragments: vec![WebUIFragment::component("mp-app")],
2640 },
2641 );
2642 fragments.insert(
2643 "mp-app".to_string(),
2644 FragmentList {
2645 fragments: vec![
2646 WebUIFragment::component("mp-category-nav"),
2647 WebUIFragment::route_from(WebUiFragmentRoute {
2648 path: "/search/:category".into(),
2649 fragment_id: "mp-search-page".into(),
2650 exact: true,
2651 keep_alive: false,
2652 ..Default::default()
2653 }),
2654 WebUIFragment::route_from(WebUiFragmentRoute {
2655 path: "/product/:handle".into(),
2656 fragment_id: "mp-product-page".into(),
2657 exact: true,
2658 keep_alive: false,
2659 ..Default::default()
2660 }),
2661 ],
2662 },
2663 );
2664 fragments.insert(
2665 "mp-category-nav".to_string(),
2666 FragmentList {
2667 fragments: vec![WebUIFragment::raw("<nav></nav>")],
2668 },
2669 );
2670 fragments.insert(
2671 "mp-search-page".to_string(),
2672 FragmentList {
2673 fragments: vec![WebUIFragment::component("mp-product-grid")],
2674 },
2675 );
2676 fragments.insert(
2677 "mp-product-grid".to_string(),
2678 FragmentList {
2679 fragments: vec![WebUIFragment::raw("<div>grid</div>")],
2680 },
2681 );
2682 fragments.insert(
2683 "mp-product-page".to_string(),
2684 FragmentList {
2685 fragments: vec![WebUIFragment::component("mp-product-detail")],
2686 },
2687 );
2688 fragments.insert(
2689 "mp-product-detail".to_string(),
2690 FragmentList {
2691 fragments: vec![WebUIFragment::raw("<div>detail</div>")],
2692 },
2693 );
2694
2695 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2696 protocol
2697 .components
2698 .entry("mp-search-page".to_string())
2699 .or_default()
2700 .template = "<mp-search-page></mp-search-page>".to_string();
2701 protocol
2702 .components
2703 .entry("mp-product-page".to_string())
2704 .or_default()
2705 .template = "<mp-product-page></mp-product-page>".to_string();
2706
2707 let comp_index = build_component_index(&protocol);
2708 let (needed, _inv) = get_needed_components_for_request(
2709 &protocol,
2710 "index.html",
2711 "/search/shirts",
2712 "",
2713 (&comp_index, &CompiledRouteIndex::new(&protocol)),
2714 )
2715 .unwrap();
2716 let needed: HashSet<String> = needed.into_iter().collect();
2717
2718 assert!(needed.contains("mp-app"));
2719 assert!(needed.contains("mp-category-nav"));
2720 assert!(needed.contains("mp-search-page"));
2721 assert!(needed.contains("mp-product-grid"));
2722 assert!(!needed.contains("mp-product-page"));
2723 assert!(!needed.contains("mp-product-detail"));
2724 }
2725
2726 #[test]
2727 fn test_get_needed_components_for_request_follows_nested_route_chain() {
2728 let mut fragments = HashMap::new();
2729 fragments.insert(
2730 "index.html".to_string(),
2731 FragmentList {
2732 fragments: vec![WebUIFragment::component("mp-app")],
2733 },
2734 );
2735 fragments.insert(
2736 "mp-app".to_string(),
2737 FragmentList {
2738 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2739 path: "/account/*rest".into(),
2740 fragment_id: "mp-account-shell".into(),
2741 exact: false,
2742 keep_alive: false,
2743 ..Default::default()
2744 })],
2745 },
2746 );
2747 fragments.insert(
2748 "mp-account-shell".to_string(),
2749 FragmentList {
2750 fragments: vec![
2751 WebUIFragment::component("mp-account-nav"),
2752 WebUIFragment::route_from(WebUiFragmentRoute {
2753 path: "/account/profile".into(),
2754 fragment_id: "mp-profile-page".into(),
2755 exact: true,
2756 keep_alive: false,
2757 ..Default::default()
2758 }),
2759 WebUIFragment::route_from(WebUiFragmentRoute {
2760 path: "/account/orders/:id".into(),
2761 fragment_id: "mp-order-page".into(),
2762 exact: true,
2763 keep_alive: false,
2764 ..Default::default()
2765 }),
2766 ],
2767 },
2768 );
2769 fragments.insert(
2770 "mp-account-nav".to_string(),
2771 FragmentList {
2772 fragments: vec![WebUIFragment::raw("<nav></nav>")],
2773 },
2774 );
2775 fragments.insert(
2776 "mp-profile-page".to_string(),
2777 FragmentList {
2778 fragments: vec![WebUIFragment::raw("<profile></profile>")],
2779 },
2780 );
2781 fragments.insert(
2782 "mp-order-page".to_string(),
2783 FragmentList {
2784 fragments: vec![WebUIFragment::component("mp-order-detail")],
2785 },
2786 );
2787 fragments.insert(
2788 "mp-order-detail".to_string(),
2789 FragmentList {
2790 fragments: vec![WebUIFragment::raw("<detail></detail>")],
2791 },
2792 );
2793
2794 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2795 protocol
2796 .components
2797 .entry("mp-account-shell".to_string())
2798 .or_default()
2799 .template = "<mp-account-shell></mp-account-shell>".to_string();
2800 protocol
2801 .components
2802 .entry("mp-profile-page".to_string())
2803 .or_default()
2804 .template = "<mp-profile-page></mp-profile-page>".to_string();
2805 protocol
2806 .components
2807 .entry("mp-order-page".to_string())
2808 .or_default()
2809 .template = "<mp-order-page></mp-order-page>".to_string();
2810
2811 let comp_index = build_component_index(&protocol);
2812 let (needed, _inv) = get_needed_components_for_request(
2813 &protocol,
2814 "index.html",
2815 "/account/orders/42",
2816 "",
2817 (&comp_index, &CompiledRouteIndex::new(&protocol)),
2818 )
2819 .unwrap();
2820 let needed: HashSet<String> = needed.into_iter().collect();
2821
2822 assert!(needed.contains("mp-app"));
2823 assert!(needed.contains("mp-account-shell"));
2824 assert!(needed.contains("mp-account-nav"));
2825 assert!(needed.contains("mp-order-page"));
2826 assert!(needed.contains("mp-order-detail"));
2827 assert!(!needed.contains("mp-profile-page"));
2828 }
2829
2830 #[test]
2831 fn test_get_needed_components_for_request_over_ships_if_and_loop_within_matched_route() {
2832 let mut fragments = HashMap::new();
2833 fragments.insert(
2834 "index.html".to_string(),
2835 FragmentList {
2836 fragments: vec![WebUIFragment::component("mp-app")],
2837 },
2838 );
2839 fragments.insert(
2840 "mp-app".to_string(),
2841 FragmentList {
2842 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2843 path: "/search".into(),
2844 fragment_id: "mp-search-page".into(),
2845 exact: true,
2846 keep_alive: false,
2847 ..Default::default()
2848 })],
2849 },
2850 );
2851 fragments.insert(
2852 "mp-search-page".to_string(),
2853 FragmentList {
2854 fragments: vec![
2855 WebUIFragment::if_cond(
2856 webui_protocol::ConditionExpr::identifier("showFilters"),
2857 "if-filters",
2858 ),
2859 WebUIFragment::for_loop("item", "items", "item-loop"),
2860 ],
2861 },
2862 );
2863 fragments.insert(
2864 "if-filters".to_string(),
2865 FragmentList {
2866 fragments: vec![WebUIFragment::component("mp-filter-panel")],
2867 },
2868 );
2869 fragments.insert(
2870 "item-loop".to_string(),
2871 FragmentList {
2872 fragments: vec![WebUIFragment::component("mp-item-card")],
2873 },
2874 );
2875 fragments.insert(
2876 "mp-filter-panel".to_string(),
2877 FragmentList {
2878 fragments: vec![WebUIFragment::raw("<filters></filters>")],
2879 },
2880 );
2881 fragments.insert(
2882 "mp-item-card".to_string(),
2883 FragmentList {
2884 fragments: vec![WebUIFragment::raw("<item></item>")],
2885 },
2886 );
2887
2888 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2889 protocol
2890 .components
2891 .entry("mp-search-page".to_string())
2892 .or_default()
2893 .template = "<mp-search-page></mp-search-page>".to_string();
2894
2895 let comp_index = build_component_index(&protocol);
2896 let (needed, _inv) = get_needed_components_for_request(
2897 &protocol,
2898 "index.html",
2899 "/search",
2900 "",
2901 (&comp_index, &CompiledRouteIndex::new(&protocol)),
2902 )
2903 .unwrap();
2904 let needed: HashSet<String> = needed.into_iter().collect();
2905
2906 assert!(needed.contains("mp-app"));
2907 assert!(needed.contains("mp-search-page"));
2908 assert!(needed.contains("mp-filter-panel"));
2909 assert!(needed.contains("mp-item-card"));
2910 }
2911
2912 #[test]
2913 fn test_get_needed_components_for_request_respects_inventory() {
2914 let mut fragments = HashMap::new();
2915 fragments.insert(
2916 "index.html".to_string(),
2917 FragmentList {
2918 fragments: vec![WebUIFragment::component("mp-app")],
2919 },
2920 );
2921 fragments.insert(
2922 "mp-app".to_string(),
2923 FragmentList {
2924 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2925 path: "/search".into(),
2926 fragment_id: "mp-search-page".into(),
2927 exact: true,
2928 keep_alive: false,
2929 ..Default::default()
2930 })],
2931 },
2932 );
2933 fragments.insert(
2934 "mp-search-page".to_string(),
2935 FragmentList {
2936 fragments: vec![WebUIFragment::component("mp-product-grid")],
2937 },
2938 );
2939 fragments.insert(
2940 "mp-product-grid".to_string(),
2941 FragmentList {
2942 fragments: vec![WebUIFragment::raw("<grid></grid>")],
2943 },
2944 );
2945
2946 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2947 for name in ["mp-app", "mp-search-page", "mp-product-grid"] {
2948 protocol
2949 .components
2950 .entry(name.to_string())
2951 .or_default()
2952 .template = format!("<f-template id=\"{name}\"></f-template>");
2953 }
2954
2955 let comp_index = build_component_index(&protocol);
2956 let route_index = CompiledRouteIndex::new(&protocol);
2957 let (_needed, inventory) = get_needed_components_for_request(
2958 &protocol,
2959 "index.html",
2960 "/search",
2961 "",
2962 (&comp_index, &route_index),
2963 )
2964 .unwrap();
2965 let (needed_again, _) = get_needed_components_for_request(
2966 &protocol,
2967 "index.html",
2968 "/search",
2969 &inventory,
2970 (&comp_index, &route_index),
2971 )
2972 .unwrap();
2973 assert!(needed_again.is_empty());
2974 }
2975
2976 #[test]
2977 fn test_inventory_includes_boundary_components_for_unmatched_sibling_routes() {
2978 let mut fragments = HashMap::new();
2984 fragments.insert(
2985 "index.html".to_string(),
2986 FragmentList {
2987 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2988 path: "/".into(),
2989 fragment_id: "route-shell".into(),
2990 children: vec![
2991 WebUiFragmentRoute {
2992 path: "slow".into(),
2993 fragment_id: "page-slow".into(),
2994 exact: true,
2995 pending_component: "loading-skeleton".into(),
2996 ..Default::default()
2997 },
2998 WebUiFragmentRoute {
2999 path: "failing".into(),
3000 fragment_id: "page-failing".into(),
3001 exact: true,
3002 error_component: "error-display".into(),
3003 ..Default::default()
3004 },
3005 ],
3006 ..Default::default()
3007 })],
3008 },
3009 );
3010 for id in [
3011 "route-shell",
3012 "page-slow",
3013 "page-failing",
3014 "loading-skeleton",
3015 "error-display",
3016 ] {
3017 fragments.insert(
3018 id.to_string(),
3019 FragmentList {
3020 fragments: vec![WebUIFragment::raw("<x></x>")],
3021 },
3022 );
3023 }
3024
3025 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3026 for id in [
3027 "route-shell",
3028 "page-slow",
3029 "page-failing",
3030 "loading-skeleton",
3031 "error-display",
3032 ] {
3033 protocol
3034 .components
3035 .entry(id.to_string())
3036 .or_default()
3037 .template = format!("<f-template id=\"{id}\"></f-template>");
3038 }
3039
3040 let reachable = collect_reachable_components_for_request(
3042 &protocol,
3043 "index.html",
3044 "/",
3045 &CompiledRouteIndex::new(&protocol),
3046 );
3047 assert!(
3048 reachable.contains("loading-skeleton"),
3049 "pending component of an unmatched sibling route must be inventoried: {reachable:?}"
3050 );
3051 assert!(
3052 reachable.contains("error-display"),
3053 "error component of an unmatched sibling route must be inventoried: {reachable:?}"
3054 );
3055 }
3056
3057 #[test]
3058 fn component_reachability_propagates_route_dependency_to_parent() {
3059 let protocol = WebUIProtocol::new(HashMap::from([
3060 (
3061 "comp-a".to_string(),
3062 FragmentList {
3063 fragments: vec![WebUIFragment::component("comp-b")],
3064 },
3065 ),
3066 (
3067 "comp-b".to_string(),
3068 FragmentList {
3069 fragments: vec![WebUIFragment::route("/", "page-a")],
3070 },
3071 ),
3072 (
3073 "page-a".to_string(),
3074 FragmentList {
3075 fragments: vec![WebUIFragment::raw("<p>page</p>")],
3076 },
3077 ),
3078 ]));
3079 let component_index = build_component_index(&protocol);
3080 let reachability = ComponentReachabilityIndex::new(&protocol, &component_index);
3081
3082 assert_eq!(
3083 reachability.is_route_dependent(component_index["comp-a"]),
3084 Some(true)
3085 );
3086 assert_eq!(
3087 reachability.is_route_dependent(component_index["comp-b"]),
3088 Some(true)
3089 );
3090 assert_eq!(
3091 reachability.is_route_dependent(component_index["page-a"]),
3092 Some(false)
3093 );
3094 }
3095
3096 #[test]
3097 fn request_reachability_visits_reused_route_component_at_each_base() {
3098 let mut protocol = WebUIProtocol::new(HashMap::from([
3099 (
3100 "shared-shell".to_string(),
3101 FragmentList {
3102 fragments: vec![WebUIFragment::route("details", "detail-page")],
3103 },
3104 ),
3105 (
3106 "detail-page".to_string(),
3107 FragmentList {
3108 fragments: vec![WebUIFragment::raw("<p>Detail</p>")],
3109 },
3110 ),
3111 ]));
3112 for tag in ["shared-shell", "detail-page"] {
3113 protocol
3114 .components
3115 .entry(tag.to_string())
3116 .or_default()
3117 .template = "<template></template>".to_string();
3118 }
3119 let route_index = CompiledRouteIndex::new(&protocol);
3120 let handler_protocol = Protocol::new(protocol.clone());
3121 let reachability = handler_protocol.component_reachability();
3122 let shell = handler_protocol.component_index()["shared-shell"];
3123 let roots = [
3124 (shell, Some(Box::from("/other"))),
3125 (shell, Some(Box::from("/account"))),
3126 ];
3127
3128 let reachable = collect_reachable_components_from_roots(
3129 &protocol,
3130 &roots,
3131 reachability,
3132 "/account/details",
3133 &route_index,
3134 );
3135
3136 assert!(
3137 reachable.iter().any(|tag| tag == "detail-page"),
3138 "the second route base must not be hidden by fragment-id deduplication: {reachable:?}"
3139 );
3140 }
3141
3142 #[test]
3143 fn component_reachability_index_is_lazy() {
3144 let protocol = Protocol::new(WebUIProtocol::new(HashMap::from([(
3145 "comp-a".to_string(),
3146 FragmentList {
3147 fragments: vec![WebUIFragment::raw("<p>leaf</p>")],
3148 },
3149 )])));
3150
3151 assert!(protocol.component_reachability.get().is_none());
3152 assert_eq!(
3153 protocol
3154 .component_reachability()
3155 .is_route_dependent(protocol.component_index["comp-a"]),
3156 Some(false)
3157 );
3158 assert!(protocol.component_reachability.get().is_some());
3159 }
3160
3161 #[test]
3162 fn test_render_partial_separates_module_styles_from_templates() {
3163 let mut fragments = HashMap::new();
3164 fragments.insert(
3165 "index.html".to_string(),
3166 FragmentList {
3167 fragments: vec![WebUIFragment::component("my-page")],
3168 },
3169 );
3170 fragments.insert(
3171 "my-page".to_string(),
3172 FragmentList {
3173 fragments: vec![WebUIFragment::raw("<p>page</p>")],
3174 },
3175 );
3176
3177 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3178 let component = protocol
3179 .components
3180 .entry("my-page".to_string())
3181 .or_default();
3182 component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
3183 component.css = ".page{color:red}".to_string();
3184
3185 let mut index = ProtocolIndex::new(&protocol);
3186 let partial =
3187 render_partial_metadata(&protocol, "index.html", "/", "", &mut index).unwrap();
3188 let styles = partial["templateStyles"]
3189 .as_array()
3190 .expect("templateStyles should be an array");
3191 assert_eq!(styles.len(), 1);
3192 let style_html = styles[0].as_str().unwrap_or_default();
3193 assert!(
3194 style_html.starts_with(r#"<script type="importmap""#)
3195 && style_html.contains(r#""my-page":"data:text/css,"#),
3196 "module style entry should be an importmap registering the component specifier: {style_html}"
3197 );
3198 assert!(
3199 style_html.contains(".page{color:red}"),
3200 "module style entry should contain the CSS content verbatim inside the data: URI: {style_html}"
3201 );
3202
3203 let templates = partial["templates"]
3205 .as_object()
3206 .expect("templates should be an object");
3207 assert_eq!(templates.len(), 1);
3208 let template = templates.get("my-page").expect("my-page template");
3209 assert_eq!(template["h"], "<p>page</p>");
3210 }
3211
3212 #[test]
3213 fn test_render_partial_link_strategy_has_empty_template_styles() {
3214 let mut fragments = HashMap::new();
3217 fragments.insert(
3218 "index.html".to_string(),
3219 FragmentList {
3220 fragments: vec![WebUIFragment::component("my-page")],
3221 },
3222 );
3223 fragments.insert(
3224 "my-page".to_string(),
3225 FragmentList {
3226 fragments: vec![WebUIFragment::raw("<p>page</p>")],
3227 },
3228 );
3229
3230 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3231 let component = protocol
3232 .components
3233 .entry("my-page".to_string())
3234 .or_default();
3235 component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
3236 component.css_href = "my-page.css".to_string();
3237 let mut index = ProtocolIndex::new(&protocol);
3240 let partial =
3241 render_partial_metadata(&protocol, "index.html", "/", "", &mut index).unwrap();
3242 let styles = partial["templateStyles"]
3243 .as_array()
3244 .expect("templateStyles should be an array");
3245 let templates = partial["templates"]
3246 .as_object()
3247 .expect("templates should be an object");
3248
3249 assert!(
3250 styles.is_empty(),
3251 "Link strategy should produce empty templateStyles: {styles:?}"
3252 );
3253 assert_eq!(templates.len(), 1, "should include template metadata");
3254 }
3255
3256 #[test]
3257 fn test_render_partial_style_strategy_has_empty_template_styles() {
3258 let mut fragments = HashMap::new();
3262 fragments.insert(
3263 "index.html".to_string(),
3264 FragmentList {
3265 fragments: vec![WebUIFragment::component("my-page")],
3266 },
3267 );
3268 fragments.insert(
3269 "my-page".to_string(),
3270 FragmentList {
3271 fragments: vec![WebUIFragment::raw("<p>page</p>")],
3272 },
3273 );
3274
3275 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3276 let component = protocol
3277 .components
3278 .entry("my-page".to_string())
3279 .or_default();
3280 component.template_json = r#"{"h":"<style>.p{color:red}</style><p>page</p>"}"#.to_string();
3282 let mut index = ProtocolIndex::new(&protocol);
3285 let partial =
3286 render_partial_metadata(&protocol, "index.html", "/", "", &mut index).unwrap();
3287 let styles = partial["templateStyles"]
3288 .as_array()
3289 .expect("templateStyles should be an array");
3290 let templates = partial["templates"]
3291 .as_object()
3292 .expect("templates should be an object");
3293
3294 assert!(
3295 styles.is_empty(),
3296 "Style strategy should produce empty templateStyles: {styles:?}"
3297 );
3298 assert_eq!(templates.len(), 1, "should include template metadata");
3299 assert!(
3300 templates
3301 .get("my-page")
3302 .and_then(|template| template["h"].as_str())
3303 .unwrap_or_default()
3304 .contains("<style>"),
3305 "Style strategy template should contain inline <style> tag"
3306 );
3307 }
3308
3309 #[test]
3310 fn test_render_partial_empty_styles_for_no_css_components() {
3311 let mut fragments = HashMap::new();
3312 fragments.insert(
3313 "index.html".to_string(),
3314 FragmentList {
3315 fragments: vec![WebUIFragment::component("my-page")],
3316 },
3317 );
3318 fragments.insert(
3319 "my-page".to_string(),
3320 FragmentList {
3321 fragments: vec![WebUIFragment::raw("<p>page</p>")],
3322 },
3323 );
3324
3325 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3326 let component = protocol
3327 .components
3328 .entry("my-page".to_string())
3329 .or_default();
3330 component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
3331 let mut index = ProtocolIndex::new(&protocol);
3334 let partial =
3335 render_partial_metadata(&protocol, "index.html", "/", "", &mut index).unwrap();
3336 let styles = partial["templateStyles"]
3337 .as_array()
3338 .expect("templateStyles should be an array");
3339 assert!(
3340 styles.is_empty(),
3341 "templateStyles should be empty when components have no CSS"
3342 );
3343 }
3344
3345 #[test]
3346 fn test_render_partial_sends_styles_even_when_templates_filtered_by_inventory() {
3347 let mut fragments = HashMap::new();
3348 fragments.insert(
3349 "index.html".to_string(),
3350 FragmentList {
3351 fragments: vec![WebUIFragment::component("my-page")],
3352 },
3353 );
3354 fragments.insert(
3355 "my-page".to_string(),
3356 FragmentList {
3357 fragments: vec![WebUIFragment::raw("<p>page</p>")],
3358 },
3359 );
3360
3361 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3362 let component = protocol
3363 .components
3364 .entry("my-page".to_string())
3365 .or_default();
3366 component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
3367 component.css = ".page{color:red}".to_string();
3368
3369 let mut index = ProtocolIndex::new(&protocol);
3370 let partial1 =
3372 render_partial_metadata(&protocol, "index.html", "/", "", &mut index).unwrap();
3373 let inv = partial1["inventory"].as_str().unwrap_or_default();
3374 assert!(!inv.is_empty());
3375
3376 let partial2 =
3381 render_partial_metadata(&protocol, "index.html", "/", inv, &mut index).unwrap();
3382 let styles = partial2["templateStyles"]
3383 .as_array()
3384 .expect("templateStyles should be an array");
3385 let templates = partial2["templates"]
3386 .as_object()
3387 .expect("templates should be an object");
3388
3389 assert!(
3390 templates.is_empty(),
3391 "templates should be empty when inventory is full"
3392 );
3393 assert!(
3394 styles.is_empty(),
3395 "module styles should be empty when inventory is full — SSR already placed them"
3396 );
3397 }
3398
3399 #[test]
3400 fn test_non_route_siblings_included_in_needed_components() {
3401 let mut fragments = HashMap::new();
3406
3407 fragments.insert(
3409 "index.html".to_string(),
3410 FragmentList {
3411 fragments: vec![WebUIFragment::component("app-shell")],
3412 },
3413 );
3414
3415 fragments.insert(
3417 "app-shell".to_string(),
3418 FragmentList {
3419 fragments: vec![
3420 WebUIFragment::component("my-navbar"),
3421 WebUIFragment::route_from(WebUiFragmentRoute {
3422 path: "/about".into(),
3423 fragment_id: "page-about".into(),
3424 exact: true,
3425 keep_alive: false,
3426 ..Default::default()
3427 }),
3428 WebUIFragment::component("cart-panel"),
3429 ],
3430 },
3431 );
3432
3433 fragments.insert(
3435 "my-navbar".to_string(),
3436 FragmentList {
3437 fragments: vec![WebUIFragment::raw("<nav/>")],
3438 },
3439 );
3440 fragments.insert(
3441 "page-about".to_string(),
3442 FragmentList {
3443 fragments: vec![WebUIFragment::raw("<h1>About</h1>")],
3444 },
3445 );
3446 fragments.insert(
3447 "cart-panel".to_string(),
3448 FragmentList {
3449 fragments: vec![WebUIFragment::raw("<aside>Cart</aside>")],
3450 },
3451 );
3452
3453 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3454 for name in ["app-shell", "my-navbar", "page-about", "cart-panel"] {
3455 let comp = protocol.components.entry(name.to_string()).or_default();
3456 comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
3457 comp.css = format!(".{name}{{display:block}}");
3458 }
3459
3460 let comp_index = build_component_index(&protocol);
3461 let (needed, _inv) = get_needed_components_for_request(
3462 &protocol,
3463 "index.html",
3464 "/about",
3465 "",
3466 (&comp_index, &CompiledRouteIndex::new(&protocol)),
3467 )
3468 .unwrap();
3469
3470 assert!(
3471 needed.contains(&"app-shell".to_string()),
3472 "app-shell should be needed: {needed:?}"
3473 );
3474 assert!(
3475 needed.contains(&"my-navbar".to_string()),
3476 "my-navbar (non-route sibling) should be needed: {needed:?}"
3477 );
3478 assert!(
3479 needed.contains(&"page-about".to_string()),
3480 "page-about (active route) should be needed: {needed:?}"
3481 );
3482 assert!(
3483 needed.contains(&"cart-panel".to_string()),
3484 "cart-panel (non-route sibling) should be needed: {needed:?}"
3485 );
3486 }
3487
3488 #[test]
3491 fn test_chain_entry_to_json_includes_allowed_query() {
3492 let entry = RouteChainEntry {
3493 component: "compose-page".into(),
3494 path: "compose".into(),
3495 params: HashMap::new(),
3496 exact: true,
3497 allowed_query: "action,to,subject".into(),
3498 keep_alive: false,
3499 cache_tags: Vec::new(),
3500 invalidates: Vec::new(),
3501 pending_component: String::new(),
3502 error_component: String::new(),
3503 };
3504 let json = entry.to_json();
3505 assert_eq!(json["allowedQuery"], "action,to,subject");
3506 }
3507
3508 #[test]
3509 fn test_chain_entry_to_json_omits_empty_allowed_query() {
3510 let entry = RouteChainEntry {
3511 component: "home-page".into(),
3512 path: "/".into(),
3513 params: HashMap::new(),
3514 exact: true,
3515 allowed_query: String::new(),
3516 keep_alive: false,
3517 cache_tags: Vec::new(),
3518 invalidates: Vec::new(),
3519 pending_component: String::new(),
3520 error_component: String::new(),
3521 };
3522 let json = entry.to_json();
3523 assert!(
3524 json.get("allowedQuery").is_none(),
3525 "empty allowed_query should not appear in JSON"
3526 );
3527 }
3528
3529 #[test]
3530 fn test_collect_route_chain_carries_allowed_query() {
3531 let mut fragments = HashMap::new();
3532 fragments.insert(
3533 "index.html".to_string(),
3534 FragmentList {
3535 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
3536 path: "/".into(),
3537 fragment_id: "app-shell".into(),
3538 exact: false,
3539 children: vec![WebUiFragmentRoute {
3540 path: "compose".into(),
3541 fragment_id: "compose-page".into(),
3542 exact: true,
3543 allowed_query: "action,to".into(),
3544 keep_alive: false,
3545 ..Default::default()
3546 }],
3547 keep_alive: false,
3548 ..Default::default()
3549 })],
3550 },
3551 );
3552 fragments.insert(
3553 "app-shell".to_string(),
3554 FragmentList {
3555 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
3556 },
3557 );
3558 fragments.insert(
3559 "compose-page".to_string(),
3560 FragmentList {
3561 fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
3562 },
3563 );
3564 let mut protocol = WebUIProtocol::new(fragments);
3565 protocol
3566 .components
3567 .entry("app-shell".into())
3568 .or_default()
3569 .css_href = "/app-shell.css".into();
3570 protocol
3571 .components
3572 .entry("compose-page".into())
3573 .or_default()
3574 .template_json = r#"{"h":"<p>Compose</p>"}"#.into();
3575
3576 let chain = collect_route_chain(
3577 &protocol,
3578 "index.html",
3579 "/compose",
3580 &CompiledRouteIndex::new(&protocol),
3581 );
3582 assert_eq!(chain.len(), 2);
3583 assert_eq!(chain[0].component, "app-shell");
3584 assert!(chain[0].allowed_query.is_empty());
3585 assert_eq!(chain[1].component, "compose-page");
3586 assert_eq!(chain[1].allowed_query, "action,to");
3587 }
3588
3589 #[test]
3590 fn scriptless_partial_uses_navigation_keys_without_bootstrap_keys() {
3591 let mut fragments = HashMap::new();
3592 fragments.insert(
3593 "index.html".to_string(),
3594 FragmentList {
3595 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
3596 path: "/items".into(),
3597 fragment_id: "items-page".into(),
3598 exact: true,
3599 ..Default::default()
3600 })],
3601 },
3602 );
3603 fragments.insert(
3604 "items-page".to_string(),
3605 FragmentList {
3606 fragments: vec![WebUIFragment::raw("<p>Items</p>")],
3607 },
3608 );
3609 let mut protocol = WebUIProtocol::new(fragments);
3610 protocol.components.insert(
3611 "items-page".to_string(),
3612 webui_protocol::ComponentData {
3613 template_json: r#"{"h":"<p>Items</p>","th":1}"#.into(),
3614 navigation_mode: Some(webui_protocol::StateProjectionMode::Keys as i32),
3615 navigation_keys: vec!["items".into(), "title".into()],
3616 ..Default::default()
3617 },
3618 );
3619
3620 let partial = render_partial(
3621 &protocol,
3622 serde_json::json!({
3623 "items": [1, 2],
3624 "serverOnly": "secret",
3625 "title": "Catalog",
3626 }),
3627 "index.html",
3628 "/items",
3629 "",
3630 )
3631 .unwrap();
3632
3633 assert_eq!(
3634 partial["state"],
3635 serde_json::json!({
3636 "items": [1, 2],
3637 "title": "Catalog",
3638 })
3639 );
3640 }
3641
3642 #[test]
3643 fn partial_requires_explicit_navigation_keys() {
3644 let mut fragments = HashMap::new();
3645 fragments.insert(
3646 "index.html".to_string(),
3647 FragmentList {
3648 fragments: vec![WebUIFragment::component("authored-card")],
3649 },
3650 );
3651 fragments.insert(
3652 "authored-card".to_string(),
3653 FragmentList {
3654 fragments: vec![WebUIFragment::raw("<p>Card</p>")],
3655 },
3656 );
3657 let mut protocol = WebUIProtocol::new(fragments);
3658 protocol.components.insert(
3659 "authored-card".to_string(),
3660 webui_protocol::ComponentData {
3661 hydration_mode: webui_protocol::StateProjectionMode::Keys as i32,
3662 hydration_keys: vec!["title".into()],
3663 navigation_mode: Some(webui_protocol::StateProjectionMode::None as i32),
3664 ..Default::default()
3665 },
3666 );
3667
3668 let partial = render_partial(
3669 &protocol,
3670 serde_json::json!({
3671 "serverOnly": "secret",
3672 "title": "Authored",
3673 }),
3674 "index.html",
3675 "/",
3676 "",
3677 )
3678 .unwrap();
3679
3680 assert_eq!(partial["state"], serde_json::json!({}));
3681 }
3682
3683 #[test]
3684 fn fully_static_scriptless_partial_emits_empty_state() {
3685 let mut fragments = HashMap::new();
3686 fragments.insert(
3687 "index.html".to_string(),
3688 FragmentList {
3689 fragments: vec![WebUIFragment::component("static-card")],
3690 },
3691 );
3692 fragments.insert(
3693 "static-card".to_string(),
3694 FragmentList {
3695 fragments: vec![WebUIFragment::raw("<p>Static</p>")],
3696 },
3697 );
3698 let mut protocol = WebUIProtocol::new(fragments);
3699 protocol.components.insert(
3700 "static-card".to_string(),
3701 webui_protocol::ComponentData {
3702 template_json: r#"{"h":"<p>Static</p>","th":1}"#.into(),
3703 navigation_mode: Some(webui_protocol::StateProjectionMode::None as i32),
3704 ..Default::default()
3705 },
3706 );
3707
3708 let partial = render_partial(
3709 &protocol,
3710 serde_json::json!({ "serverOnly": "secret" }),
3711 "index.html",
3712 "/",
3713 "",
3714 )
3715 .unwrap();
3716
3717 assert_eq!(partial["state"], serde_json::json!({}));
3718 }
3719
3720 #[test]
3721 fn test_render_component_templates_returns_template_and_css() {
3722 let mut fragments = HashMap::new();
3723 fragments.insert(
3724 "settings-dialog".to_string(),
3725 FragmentList {
3726 fragments: vec![WebUIFragment::raw("<div class='dialog'>Settings</div>")],
3727 },
3728 );
3729 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3730 let comp = protocol
3731 .components
3732 .entry("settings-dialog".to_string())
3733 .or_default();
3734 comp.template_json = r#"{"h":"<div>Settings</div>"}"#.to_string();
3735 comp.css = ".dialog{position:fixed}".to_string();
3736
3737 let mut index = ProtocolIndex::new(&protocol);
3738 let result =
3739 render_component_templates(&protocol, &["settings-dialog"], "", &mut index).unwrap();
3740 let templates = result["templates"].as_object().expect("templates object");
3741 let styles = result["templateStyles"].as_array().expect("styles array");
3742
3743 assert_eq!(templates.len(), 1);
3744 assert_eq!(templates["settings-dialog"]["h"], "<div>Settings</div>");
3745 assert_eq!(styles.len(), 1);
3746 let style_html = styles[0].as_str().unwrap();
3747 assert!(
3748 style_html.starts_with(r#"<script type="importmap""#)
3749 && style_html.contains(r#""settings-dialog":"data:text/css,"#),
3750 "templateStyles entry should be an importmap registering settings-dialog: {style_html}"
3751 );
3752 assert!(
3755 style_html.contains(".dialog{position:fixed}"),
3756 "templateStyles entry should contain the CSS content verbatim: {style_html}"
3757 );
3758 assert!(
3759 index
3760 .template_metadata_cache
3761 .contains_key("settings-dialog"),
3762 "parsed static template metadata should be cached in ProtocolIndex"
3763 );
3764 }
3765
3766 #[test]
3767 fn test_render_component_templates_respects_inventory() {
3768 let mut fragments = HashMap::new();
3769 fragments.insert(
3770 "my-dialog".to_string(),
3771 FragmentList {
3772 fragments: vec![WebUIFragment::raw("<div>Dialog</div>")],
3773 },
3774 );
3775 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3776 let comp = protocol
3777 .components
3778 .entry("my-dialog".to_string())
3779 .or_default();
3780 comp.template_json = r#"{"h":"<div>Dialog</div>"}"#.to_string();
3781 comp.css = ".d{color:red}".to_string();
3782
3783 let mut index = ProtocolIndex::new(&protocol);
3784 let result1 =
3786 render_component_templates(&protocol, &["my-dialog"], "", &mut index).unwrap();
3787 let inv = result1["inventory"].as_str().expect("inventory string");
3788 assert_eq!(result1["templates"].as_object().unwrap().len(), 1);
3789
3790 let result2 =
3792 render_component_templates(&protocol, &["my-dialog"], inv, &mut index).unwrap();
3793 assert_eq!(result2["templates"].as_object().unwrap().len(), 0);
3794 assert_eq!(result2["templateStyles"].as_array().unwrap().len(), 0);
3795 }
3796
3797 #[test]
3798 fn test_render_component_templates_unknown_component_returns_empty() {
3799 let fragments = HashMap::new();
3800 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
3801
3802 let mut index = ProtocolIndex::new(&protocol);
3803 let result =
3804 render_component_templates(&protocol, &["nonexistent-widget"], "", &mut index).unwrap();
3805 assert_eq!(result["templates"].as_object().unwrap().len(), 0);
3806 assert_eq!(result["templateStyles"].as_array().unwrap().len(), 0);
3807 }
3808
3809 #[test]
3810 fn test_render_partial_includes_sibling_boundary_templates() {
3811 let mut fragments = HashMap::new();
3812 fragments.insert(
3813 "index.html".to_string(),
3814 FragmentList {
3815 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
3816 path: "/".into(),
3817 fragment_id: "app-shell".into(),
3818 exact: false,
3819 children: vec![
3820 WebUiFragmentRoute {
3821 path: "inbox".into(),
3822 fragment_id: "inbox-page".into(),
3823 exact: true,
3824 pending_component: "inbox-loading".into(),
3825 ..Default::default()
3826 },
3827 WebUiFragmentRoute {
3828 path: "settings".into(),
3829 fragment_id: "settings-page".into(),
3830 exact: true,
3831 pending_component: "settings-loading".into(),
3832 error_component: "settings-error".into(),
3833 ..Default::default()
3834 },
3835 ],
3836 ..Default::default()
3837 })],
3838 },
3839 );
3840
3841 for name in [
3842 "app-shell",
3843 "inbox-page",
3844 "inbox-loading",
3845 "settings-page",
3846 "settings-loading",
3847 "settings-error",
3848 ] {
3849 fragments.insert(
3850 name.to_string(),
3851 FragmentList {
3852 fragments: vec![WebUIFragment::raw(format!("<p>{name}</p>"))],
3853 },
3854 );
3855 }
3856
3857 let mut protocol = WebUIProtocol::new(fragments);
3858 for name in [
3859 "app-shell",
3860 "inbox-page",
3861 "inbox-loading",
3862 "settings-page",
3863 "settings-loading",
3864 "settings-error",
3865 ] {
3866 protocol
3867 .components
3868 .entry(name.to_string())
3869 .or_default()
3870 .template_json = format!(r#"{{"h":"<p>{name}</p>"}}"#);
3871 }
3872
3873 let mut index = ProtocolIndex::new(&protocol);
3874 let partial =
3875 render_partial_metadata(&protocol, "index.html", "/inbox", "", &mut index).unwrap();
3876 let templates = partial["templates"].as_object().unwrap();
3877
3878 assert!(
3879 templates.contains_key("settings-loading"),
3880 "sibling pending template should be preloaded for route transitions: {partial}"
3881 );
3882 assert!(
3883 templates.contains_key("settings-error"),
3884 "sibling error template should be preloaded for failed route transitions: {partial}"
3885 );
3886 let chain = partial["chain"].as_array().unwrap();
3887 assert_eq!(chain.len(), 2);
3888 assert_eq!(chain[0]["component"], "app-shell");
3889 assert_eq!(chain[1]["component"], "inbox-page");
3890 }
3891
3892 #[test]
3895 fn test_resolve_tag_templates_no_placeholders() {
3896 let tags = vec!["inbox".to_string(), "counts".to_string()];
3897 let params = HashMap::new();
3898 let resolved = resolve_tag_templates(&tags, ¶ms);
3899 assert_eq!(resolved, vec!["inbox", "counts"]);
3900 }
3901
3902 #[test]
3903 fn test_resolve_tag_templates_with_params() {
3904 let tags = vec!["thread:{threadId}".to_string(), "inbox".to_string()];
3905 let mut params = HashMap::new();
3906 params.insert("threadId".to_string(), "42".to_string());
3907 let resolved = resolve_tag_templates(&tags, ¶ms);
3908 assert_eq!(resolved, vec!["thread:42", "inbox"]);
3909 }
3910
3911 #[test]
3912 fn test_resolve_tag_templates_multiple_params() {
3913 let tags = vec!["folder:{folderId}:user:{userId}".to_string()];
3914 let mut params = HashMap::new();
3915 params.insert("folderId".to_string(), "drafts".to_string());
3916 params.insert("userId".to_string(), "abc".to_string());
3917 let resolved = resolve_tag_templates(&tags, ¶ms);
3918 assert_eq!(resolved, vec!["folder:drafts:user:abc"]);
3919 }
3920
3921 #[test]
3922 fn test_resolve_tag_templates_missing_param_left_unresolved() {
3923 let tags = vec!["thread:{threadId}".to_string()];
3924 let params = HashMap::new();
3925 let resolved = resolve_tag_templates(&tags, ¶ms);
3926 assert_eq!(resolved, vec!["thread:{threadId}"]);
3927 }
3928
3929 #[test]
3932 fn test_chain_entry_to_json_includes_new_fields() {
3933 let entry = RouteChainEntry {
3934 component: "mail-thread".into(),
3935 path: "email/:threadId".into(),
3936 params: {
3937 let mut p = HashMap::new();
3938 p.insert("threadId".to_string(), "42".to_string());
3939 p
3940 },
3941 exact: true,
3942 allowed_query: String::new(),
3943 keep_alive: false,
3944 cache_tags: vec!["thread:42".to_string()],
3945 invalidates: vec!["inbox".to_string(), "counts".to_string()],
3946 pending_component: "mail-skeleton".into(),
3947 error_component: "error-page".into(),
3948 };
3949 let json = entry.to_json();
3950 assert_eq!(json["pendingComponent"], "mail-skeleton");
3951 assert_eq!(json["errorComponent"], "error-page");
3952 let inv = json["invalidates"].as_array().unwrap();
3953 assert_eq!(inv.len(), 2);
3954 assert_eq!(inv[0], "inbox");
3955 assert_eq!(inv[1], "counts");
3956 }
3957
3958 #[test]
3959 fn test_chain_entry_to_json_omits_empty_new_fields() {
3960 let entry = RouteChainEntry {
3961 component: "home-page".into(),
3962 path: "/".into(),
3963 params: HashMap::new(),
3964 exact: true,
3965 allowed_query: String::new(),
3966 keep_alive: false,
3967 cache_tags: Vec::new(),
3968 invalidates: Vec::new(),
3969 pending_component: String::new(),
3970 error_component: String::new(),
3971 };
3972 let json = entry.to_json();
3973 assert!(json.get("pendingComponent").is_none());
3974 assert!(json.get("errorComponent").is_none());
3975 assert!(json.get("invalidates").is_none());
3976 }
3977
3978 #[test]
3981 fn test_render_partial_includes_resolved_cache_tags() {
3982 let mut fragments = HashMap::new();
3983 fragments.insert(
3984 "index.html".to_string(),
3985 FragmentList {
3986 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
3987 path: "/".into(),
3988 fragment_id: "app-shell".into(),
3989 exact: false,
3990 children: vec![WebUiFragmentRoute {
3991 path: "email/:threadId".into(),
3992 fragment_id: "mail-thread".into(),
3993 exact: true,
3994 cache_tags: vec!["thread:{threadId}".to_string(), "inbox".to_string()],
3995 ..Default::default()
3996 }],
3997 cache_tags: vec!["folders".to_string()],
3998 ..Default::default()
3999 })],
4000 },
4001 );
4002 fragments.insert(
4003 "app-shell".to_string(),
4004 FragmentList {
4005 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
4006 },
4007 );
4008 fragments.insert(
4009 "mail-thread".to_string(),
4010 FragmentList {
4011 fragments: vec![WebUIFragment::raw("<p>Thread</p>")],
4012 },
4013 );
4014 let protocol = WebUIProtocol::new(fragments);
4015
4016 let mut index = ProtocolIndex::new(&protocol);
4017 let partial =
4018 render_partial_metadata(&protocol, "index.html", "/email/42", "", &mut index).unwrap();
4019 let tags = partial["cacheTags"].as_array().unwrap();
4020 let tag_strings: Vec<&str> = tags.iter().map(|v| v.as_str().unwrap()).collect();
4021 assert!(
4022 tag_strings.contains(&"folders"),
4023 "should contain parent tags"
4024 );
4025 assert!(
4026 tag_strings.contains(&"thread:42"),
4027 "should resolve threadId param to 42"
4028 );
4029 assert!(tag_strings.contains(&"inbox"), "should contain static tags");
4030 }
4031
4032 #[test]
4035 fn test_render_action_response_returns_resolved_invalidation_tags() {
4036 let mut fragments = HashMap::new();
4037 fragments.insert(
4038 "index.html".to_string(),
4039 FragmentList {
4040 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
4041 path: "/".into(),
4042 fragment_id: "app-shell".into(),
4043 exact: false,
4044 children: vec![WebUiFragmentRoute {
4045 path: "compose".into(),
4046 fragment_id: "compose-page".into(),
4047 exact: true,
4048 invalidates: vec![
4049 "inbox".to_string(),
4050 "sent".to_string(),
4051 "counts".to_string(),
4052 ],
4053 ..Default::default()
4054 }],
4055 ..Default::default()
4056 })],
4057 },
4058 );
4059 fragments.insert(
4060 "app-shell".to_string(),
4061 FragmentList {
4062 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
4063 },
4064 );
4065 fragments.insert(
4066 "compose-page".to_string(),
4067 FragmentList {
4068 fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
4069 },
4070 );
4071 let protocol = WebUIProtocol::new(fragments);
4072
4073 let mut index = ProtocolIndex::new(&protocol);
4074 let result = render_action_response(
4075 &protocol,
4076 serde_json::json!({"ok": true}),
4077 "index.html",
4078 "/compose",
4079 &mut index,
4080 );
4081
4082 let tags = result["invalidateTags"].as_array().unwrap();
4083 let tag_strings: Vec<&str> = tags.iter().map(|v| v.as_str().unwrap()).collect();
4084 assert_eq!(tag_strings, vec!["inbox", "sent", "counts"]);
4085 assert_eq!(result["state"]["ok"], true);
4086 }
4087
4088 #[test]
4089 fn test_render_action_response_resolves_param_placeholders() {
4090 let mut fragments = HashMap::new();
4091 fragments.insert(
4092 "index.html".to_string(),
4093 FragmentList {
4094 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
4095 path: "/".into(),
4096 fragment_id: "app-shell".into(),
4097 exact: false,
4098 children: vec![WebUiFragmentRoute {
4099 path: "email/:threadId/reply".into(),
4100 fragment_id: "reply-page".into(),
4101 exact: true,
4102 invalidates: vec!["thread:{threadId}".to_string(), "inbox".to_string()],
4103 ..Default::default()
4104 }],
4105 ..Default::default()
4106 })],
4107 },
4108 );
4109 fragments.insert(
4110 "app-shell".to_string(),
4111 FragmentList {
4112 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
4113 },
4114 );
4115 fragments.insert(
4116 "reply-page".to_string(),
4117 FragmentList {
4118 fragments: vec![WebUIFragment::raw("<p>Reply</p>")],
4119 },
4120 );
4121 let protocol = WebUIProtocol::new(fragments);
4122
4123 let mut index = ProtocolIndex::new(&protocol);
4124 let result = render_action_response(
4125 &protocol,
4126 serde_json::json!({}),
4127 "index.html",
4128 "/email/42/reply",
4129 &mut index,
4130 );
4131
4132 let tags = result["invalidateTags"].as_array().unwrap();
4133 let tag_strings: Vec<&str> = tags.iter().map(|v| v.as_str().unwrap()).collect();
4134 assert!(tag_strings.contains(&"thread:42"));
4135 assert!(tag_strings.contains(&"inbox"));
4136 }
4137}