Skip to main content

webui_handler/
route_handler.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Route component inventory management for incremental route rendering.
5//!
6//! These helpers walk the normal render fragment graph. The request-aware path is
7//! route-aware but state-agnostic: it follows the active route chain for the
8//! current request path, while conservatively traversing `if`, `for`, and
9//! attribute-template edges without evaluating runtime state.
10
11use crate::route_matcher;
12use serde_json::Value;
13use std::collections::{HashMap, HashSet};
14use webui_protocol::{web_ui_fragment::Fragment, WebUIFragment, WebUIFragmentRoute, WebUIProtocol};
15
16// ── Component Inventory ─────────────────────────────────────────────────
17
18/// FNV-1a hash mod 256 — deterministic bit position for a component name.
19/// Must match the client-side implementation in `@microsoft/webui-router`.
20fn component_bit_position(name: &str) -> u32 {
21    let mut hash: u32 = 0x811c_9dc5;
22    for byte in name.as_bytes() {
23        hash ^= u32::from(*byte);
24        hash = hash.wrapping_mul(0x0100_0193);
25    }
26    hash % 256
27}
28
29/// Check if a component is present in an inventory bitmask.
30fn has_component(inventory: &[u8], name: &str) -> bool {
31    let bit = component_bit_position(name);
32    let byte_idx = (bit / 8) as usize;
33    let bit_idx = bit % 8;
34    byte_idx < inventory.len() && inventory[byte_idx] & (1 << bit_idx) != 0
35}
36
37/// Parse a hex string into bytes for the inventory bitmask.
38pub fn parse_inventory(hex: &str) -> Vec<u8> {
39    let mut bytes = Vec::with_capacity(hex.len() / 2);
40    let mut chars = hex.bytes();
41    while let (Some(hi), Some(lo)) = (chars.next(), chars.next()) {
42        if let (Some(hi_nibble), Some(lo_nibble)) = (hex_nibble(hi), hex_nibble(lo)) {
43            bytes.push((hi_nibble << 4) | lo_nibble);
44        }
45    }
46    bytes
47}
48
49fn hex_nibble(byte: u8) -> Option<u8> {
50    match byte {
51        b'0'..=b'9' => Some(byte - b'0'),
52        b'a'..=b'f' => Some(byte - b'a' + 10),
53        b'A'..=b'F' => Some(byte - b'A' + 10),
54        _ => None,
55    }
56}
57
58/// Encode an inventory bitmask as a hex string.
59pub fn encode_inventory(inv: &[u8]) -> String {
60    inv.iter()
61        .fold(String::with_capacity(inv.len() * 2), |mut acc, b| {
62            use std::fmt::Write;
63            let _ = write!(acc, "{b:02x}");
64            acc
65        })
66}
67
68/// Walk the protocol fragment graph from `entry_id` and return the names of
69/// all components the route needs that are NOT in the client's inventory.
70///
71/// `entry_id` is the route's component name (e.g., `"cb-page-group"`).
72/// `inventory` is the client's bitmask of already-loaded components.
73///
74/// Returns `(needed_names, updated_inventory_hex)`.
75pub fn get_needed_components(
76    protocol: &WebUIProtocol,
77    entry_id: &str,
78    inventory_hex: &str,
79) -> (Vec<String>, String) {
80    let component_names = collect_inventoryable_components(protocol, entry_id, None, true);
81    filter_needed_components(&component_names, inventory_hex)
82}
83
84/// Walk the protocol from the persistent `entry_id` and return the component
85/// templates needed for the current `request_path`.
86///
87/// The traversal is route-aware but state-agnostic:
88///
89/// - sibling `<route>` fragments are pruned to the single best match for the
90///   request path
91/// - nested route groups are traversed recursively via that same rule
92/// - `if`, `for`, and attribute-template edges are still followed
93///   conservatively, without evaluating runtime state
94///
95/// Returns `(needed_names, updated_inventory_hex)`.
96pub fn get_needed_components_for_request(
97    protocol: &WebUIProtocol,
98    entry_id: &str,
99    request_path: &str,
100    inventory_hex: &str,
101) -> (Vec<String>, String) {
102    let component_names =
103        collect_inventoryable_components(protocol, entry_id, Some(request_path), false);
104    filter_needed_components(&component_names, inventory_hex)
105}
106
107/// Filter an inventoryable component set against the client's inventory bitmask.
108///
109/// Returns the missing component names and the updated inventory hex string.
110#[must_use]
111pub fn filter_needed_components(
112    component_names: &HashSet<String>,
113    inventory_hex: &str,
114) -> (Vec<String>, String) {
115    let mut updated_inv = parse_inventory(inventory_hex);
116    updated_inv.resize(32, 0);
117
118    let mut ordered_names: Vec<&String> = component_names.iter().collect();
119    ordered_names.sort_unstable();
120
121    let mut needed = Vec::with_capacity(ordered_names.len());
122    for name in ordered_names {
123        if has_component(&updated_inv, name) {
124            continue;
125        }
126
127        let bit = component_bit_position(name);
128        let byte_idx = (bit / 8) as usize;
129        let bit_idx = bit % 8;
130        if byte_idx < updated_inv.len() {
131            updated_inv[byte_idx] |= 1 << bit_idx;
132        }
133        needed.push(name.clone());
134    }
135
136    (needed, encode_inventory(&updated_inv))
137}
138
139/// Get the f-template HTML strings needed for a route that the client doesn't
140/// already have.
141///
142/// Walks the fragment graph from `entry_id`, identifies needed components (not
143/// in the client's inventory), and returns their client template HTML from the
144/// protocol's `components` map.
145///
146/// Returns `(templates: Vec<(name, html)>, updated_inventory_hex)`.
147pub fn get_route_templates(
148    protocol: &WebUIProtocol,
149    entry_id: &str,
150    inventory_hex: &str,
151) -> (Vec<(String, String)>, String) {
152    let (needed_names, updated_inv) = get_needed_components(protocol, entry_id, inventory_hex);
153
154    let templates: Vec<(String, String)> = needed_names
155        .iter()
156        .filter_map(|name| {
157            protocol
158                .components
159                .get(name)
160                .filter(|c| !c.template.is_empty())
161                .map(|c| (name.clone(), prepend_css_module(name, &c.css, &c.template)))
162        })
163        .collect();
164
165    (templates, updated_inv)
166}
167
168/// Get the client template HTML strings needed for the active route chain
169/// rooted at `entry_id` for the current `request_path`.
170///
171/// Returns `(templates: Vec<(name, html)>, updated_inventory_hex)`.
172pub fn get_route_templates_for_request(
173    protocol: &WebUIProtocol,
174    entry_id: &str,
175    request_path: &str,
176    inventory_hex: &str,
177) -> (Vec<(String, String)>, String) {
178    let (needed_names, updated_inv) =
179        get_needed_components_for_request(protocol, entry_id, request_path, inventory_hex);
180
181    let templates: Vec<(String, String)> = needed_names
182        .iter()
183        .filter_map(|name| {
184            protocol
185                .components
186                .get(name)
187                .filter(|c| !c.template.is_empty())
188                .map(|c| (name.clone(), prepend_css_module(name, &c.css, &c.template)))
189        })
190        .collect();
191
192    (templates, updated_inv)
193}
194
195/// Prepend the CSS module `<style type="module">` definition to a client template
196/// string for partial responses.
197fn prepend_css_module(name: &str, css: &str, tmpl: &str) -> String {
198    if css.is_empty() {
199        return tmpl.to_string();
200    }
201    let mut s = String::with_capacity(40 + name.len() + css.len() + tmpl.len());
202    s.push_str("<style type=\"module\" specifier=\"");
203    s.push_str(name);
204    s.push_str("\">");
205    s.push_str(css);
206    s.push_str("</style>");
207    s.push_str(tmpl);
208    s
209}
210
211#[derive(Debug)]
212struct QueuedFragment {
213    id: String,
214    inventoryable: bool,
215    /// Base path for resolving relative route paths at this level.
216    route_base: String,
217}
218
219/// Walk the fragment graph from `entry_id` and collect all inventoryable component names.
220///
221/// Uses an iterative stack-based traversal. When `request_path` is provided,
222/// the walk follows only the best-matching route at each nesting level (route-aware).
223/// Without a request path, all route branches are followed conservatively.
224///
225/// Components are marked `inventoryable` when they have a corresponding entry
226/// in `protocol.components` with a non-empty template — these are the components
227/// whose f-template HTML the client may need during navigation.
228fn collect_inventoryable_components(
229    protocol: &WebUIProtocol,
230    entry_id: &str,
231    request_path: Option<&str>,
232    root_inventoryable: bool,
233) -> HashSet<String> {
234    let mut visited_fragments = HashSet::new();
235    let mut component_ids = HashSet::new();
236    let mut stack = vec![QueuedFragment {
237        id: entry_id.to_string(),
238        inventoryable: root_inventoryable,
239        route_base: "/".to_string(),
240    }];
241
242    while let Some(queued) = stack.pop() {
243        if queued.id.is_empty() {
244            continue;
245        }
246
247        if queued.inventoryable {
248            component_ids.insert(queued.id.clone());
249        }
250
251        if !visited_fragments.insert(queued.id.clone()) {
252            continue;
253        }
254
255        let Some(frag_list) = protocol.fragments.get(&queued.id) else {
256            continue;
257        };
258
259        let matched_route = request_path
260            .and_then(|path| find_best_route_match(&frag_list.fragments, path, &queued.route_base));
261
262        for frag in &frag_list.fragments {
263            match frag.fragment.as_ref() {
264                Some(Fragment::Component(component)) => {
265                    stack.push(QueuedFragment {
266                        id: component.fragment_id.clone(),
267                        inventoryable: true,
268                        route_base: queued.route_base.clone(),
269                    });
270                }
271                Some(Fragment::ForLoop(for_loop)) => {
272                    stack.push(QueuedFragment {
273                        id: for_loop.fragment_id.clone(),
274                        inventoryable: false,
275                        route_base: queued.route_base.clone(),
276                    });
277                }
278                Some(Fragment::IfCond(if_cond)) => {
279                    stack.push(QueuedFragment {
280                        id: if_cond.fragment_id.clone(),
281                        inventoryable: false,
282                        route_base: queued.route_base.clone(),
283                    });
284                }
285                Some(Fragment::Attribute(attr)) if !attr.template.is_empty() => {
286                    stack.push(QueuedFragment {
287                        id: attr.template.clone(),
288                        inventoryable: false,
289                        route_base: queued.route_base.clone(),
290                    });
291                }
292                Some(Fragment::Route(route_frag)) => {
293                    let is_selected = matched_route
294                        .as_ref()
295                        .is_some_and(|(best_key, _)| best_key == route_fragment_key(route_frag));
296                    if is_selected && !route_frag.fragment_id.is_empty() {
297                        // Compute new route base from consumed segments
298                        let child_route_base = if let Some((_, ref rm)) = matched_route {
299                            if let Some(path) = request_path {
300                                route_matcher::compute_route_base(path, rm.consumed_segments)
301                            } else {
302                                queued.route_base.clone()
303                            }
304                        } else {
305                            queued.route_base.clone()
306                        };
307
308                        stack.push(QueuedFragment {
309                            id: route_frag.fragment_id.clone(),
310                            inventoryable: protocol
311                                .components
312                                .get(&route_frag.fragment_id)
313                                .is_some_and(|c| !c.template.is_empty()),
314                            route_base: child_route_base.clone(),
315                        });
316
317                        // Walk nested child routes to find the next matched level.
318                        // This mirrors the handler's outlet rendering: match children
319                        // against the request path and follow the matched chain.
320                        if !route_frag.children.is_empty() {
321                            if let Some(path) = request_path {
322                                walk_route_children(
323                                    &route_frag.children,
324                                    path,
325                                    &child_route_base,
326                                    protocol,
327                                    &mut stack,
328                                );
329                            }
330                        }
331                    }
332                }
333                _ => {}
334            }
335        }
336    }
337
338    component_ids
339}
340
341/// Walk nested route children to find matched routes and add their
342/// components to the inventory stack. Mirrors the handler's outlet rendering.
343fn walk_route_children(
344    children: &[WebUIFragmentRoute],
345    request_path: &str,
346    route_base: &str,
347    protocol: &WebUIProtocol,
348    stack: &mut Vec<QueuedFragment>,
349) {
350    let mut current = children;
351    let mut base = route_base.to_string();
352
353    loop {
354        let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
355        for (idx, child) in current.iter().enumerate() {
356            let resolved = route_matcher::resolve_route_path(&child.path, &base);
357            if let Some(m) = route_matcher::match_single_route(&resolved, request_path, child.exact)
358            {
359                let is_better = best
360                    .as_ref()
361                    .is_none_or(|(_, prev)| m.specificity > prev.specificity);
362                if is_better {
363                    best = Some((idx, m));
364                }
365            }
366        }
367
368        let Some((idx, ref rm)) = best else { break };
369        let matched = &current[idx];
370        if matched.fragment_id.is_empty() {
371            break;
372        }
373
374        let child_base = route_matcher::compute_route_base(request_path, rm.consumed_segments);
375
376        stack.push(QueuedFragment {
377            id: matched.fragment_id.clone(),
378            inventoryable: protocol
379                .components
380                .get(&matched.fragment_id)
381                .is_some_and(|c| !c.template.is_empty()),
382            route_base: child_base.clone(),
383        });
384
385        if matched.children.is_empty() {
386            break;
387        }
388        current = &matched.children;
389        base = child_base;
390    }
391}
392
393/// Pre-scan sibling route fragments and return the best match info.
394///
395/// `route_base` is used to resolve relative paths (starting with `./`).
396fn find_best_route_match(
397    fragments: &[WebUIFragment],
398    request_path: &str,
399    route_base: &str,
400) -> Option<(String, route_matcher::RouteMatch)> {
401    let mut best: Option<(String, route_matcher::RouteMatch)> = None;
402
403    for item in fragments {
404        if let Some(Fragment::Route(route_frag)) = item.fragment.as_ref() {
405            let resolved_path = route_matcher::resolve_route_path(&route_frag.path, route_base);
406            if let Some(m) =
407                route_matcher::match_single_route(&resolved_path, request_path, route_frag.exact)
408            {
409                let is_better = best
410                    .as_ref()
411                    .is_none_or(|(_, prev)| m.specificity > prev.specificity);
412
413                if is_better {
414                    best = Some((route_fragment_key(route_frag).to_string(), m));
415                }
416            }
417        }
418    }
419
420    best
421}
422
423fn route_fragment_key(route_frag: &WebUIFragmentRoute) -> &str {
424    route_frag.fragment_id.as_str()
425}
426
427/// Walk the fragment graph following matched routes and collect all route
428/// parameters from every level of the active route chain.
429///
430/// This is used by the dev server to inject nested route params into state.
431pub fn collect_nested_route_params(
432    protocol: &WebUIProtocol,
433    entry_id: &str,
434    request_path: &str,
435) -> HashMap<String, String> {
436    let mut all_params = HashMap::new();
437    let mut visited_fragments = HashSet::new();
438    let mut stack = vec![QueuedFragment {
439        id: entry_id.to_string(),
440        inventoryable: false,
441        route_base: "/".to_string(),
442    }];
443
444    while let Some(queued) = stack.pop() {
445        if queued.id.is_empty() || !visited_fragments.insert(queued.id.clone()) {
446            continue;
447        }
448
449        let Some(frag_list) = protocol.fragments.get(&queued.id) else {
450            continue;
451        };
452
453        let matched_route =
454            find_best_route_match(&frag_list.fragments, request_path, &queued.route_base);
455
456        for frag in &frag_list.fragments {
457            match frag.fragment.as_ref() {
458                Some(Fragment::Component(component)) => {
459                    stack.push(QueuedFragment {
460                        id: component.fragment_id.clone(),
461                        inventoryable: false,
462                        route_base: queued.route_base.clone(),
463                    });
464                }
465                Some(Fragment::Route(route_frag)) => {
466                    let is_selected = matched_route
467                        .as_ref()
468                        .is_some_and(|(best_key, _)| best_key == route_fragment_key(route_frag));
469                    if is_selected && !route_frag.fragment_id.is_empty() {
470                        if let Some((_, ref rm)) = matched_route {
471                            // Collect params from this route level
472                            all_params.extend(rm.params.clone());
473
474                            let child_route_base = route_matcher::compute_route_base(
475                                request_path,
476                                rm.consumed_segments,
477                            );
478
479                            stack.push(QueuedFragment {
480                                id: route_frag.fragment_id.clone(),
481                                inventoryable: false,
482                                route_base: child_route_base.clone(),
483                            });
484
485                            // Walk nested children to collect params from deeper levels
486                            collect_params_from_children(
487                                &route_frag.children,
488                                request_path,
489                                &child_route_base,
490                                &mut all_params,
491                            );
492                        }
493                    }
494                }
495                _ => {}
496            }
497        }
498    }
499
500    all_params
501}
502
503/// Recursively collect route params from nested route children.
504fn collect_params_from_children(
505    children: &[WebUIFragmentRoute],
506    request_path: &str,
507    route_base: &str,
508    all_params: &mut HashMap<String, String>,
509) {
510    let mut current = children;
511    let mut base = route_base.to_string();
512
513    loop {
514        let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
515        for (idx, child) in current.iter().enumerate() {
516            let resolved = route_matcher::resolve_route_path(&child.path, &base);
517            if let Some(m) = route_matcher::match_single_route(&resolved, request_path, child.exact)
518            {
519                let is_better = best
520                    .as_ref()
521                    .is_none_or(|(_, prev)| m.specificity > prev.specificity);
522                if is_better {
523                    best = Some((idx, m));
524                }
525            }
526        }
527
528        let Some((idx, rm)) = best else { break };
529        all_params.extend(rm.params);
530        let matched = &current[idx];
531        if matched.children.is_empty() {
532            break;
533        }
534        let child_base = route_matcher::compute_route_base(request_path, rm.consumed_segments);
535        current = &matched.children;
536        base = child_base;
537    }
538}
539
540// ── Partial Response ────────────────────────────────────────────────
541
542/// Produce a complete JSON partial response for client-side navigation.
543///
544/// Combines route templates, inventory, and matched route chain into a single
545/// JSON value. Host servers include this directly in their response alongside
546/// the application `state`.
547///
548/// Returns a `serde_json::Value` object with fields:
549/// - `state`: the application state passed through
550/// - `templates`: array of f-template HTML strings the client needs
551/// - `inventory`: updated hex bitmask
552/// - `path`: the request path
553/// - `chain`: matched route chain array
554#[must_use]
555pub fn render_partial(
556    protocol: &WebUIProtocol,
557    state: Value,
558    entry_id: &str,
559    request_path: &str,
560    inventory_hex: &str,
561) -> Value {
562    // Get needed templates (filtered by client inventory).
563    // Each template string is prepended with its CSS module definition
564    // (if any) so the client can insert both as a DocumentFragment.
565    let (templates, updated_inv) =
566        get_route_templates_for_request(protocol, entry_id, request_path, inventory_hex);
567
568    // Build the matched route chain
569    let chain = collect_route_chain(protocol, entry_id, request_path);
570
571    // Assemble the response
572    let tmpl_array: Vec<Value> = templates
573        .into_iter()
574        .map(|(_, html)| Value::String(html))
575        .collect();
576
577    let chain_array = Value::Array(chain.iter().map(RouteChainEntry::to_json).collect());
578
579    let mut result = serde_json::Map::with_capacity(5);
580    result.insert("state".into(), state);
581    result.insert("templates".into(), Value::Array(tmpl_array));
582    result.insert("inventory".into(), Value::String(updated_inv));
583    result.insert("path".into(), Value::String(request_path.to_string()));
584    result.insert("chain".into(), chain_array);
585    Value::Object(result)
586}
587
588// ── Route Chain ─────────────────────────────────────────────────────
589
590/// A single entry in the matched route chain, one per nesting level.
591#[derive(Debug, Clone)]
592pub struct RouteChainEntry {
593    /// Component tag name for this route level.
594    pub component: String,
595    /// The route path pattern (as declared in the template).
596    pub path: String,
597    /// Bound route parameters at this level.
598    pub params: HashMap<String, String>,
599    /// Whether this route requires an exact match.
600    pub exact: bool,
601}
602
603impl RouteChainEntry {
604    /// Serialize this entry to a JSON value ready for inclusion in a partial response.
605    #[must_use]
606    pub fn to_json(&self) -> Value {
607        let mut obj = serde_json::Map::with_capacity(4);
608        obj.insert("component".into(), Value::String(self.component.clone()));
609        obj.insert("path".into(), Value::String(self.path.clone()));
610        if !self.params.is_empty() {
611            let params_obj: serde_json::Map<String, Value> = self
612                .params
613                .iter()
614                .map(|(k, v)| (k.clone(), Value::String(v.clone())))
615                .collect();
616            obj.insert("params".into(), Value::Object(params_obj));
617        }
618        if self.exact {
619            obj.insert("exact".into(), Value::Bool(true));
620        }
621        Value::Object(obj)
622    }
623}
624
625/// Collect the matched route chain as a JSON array value.
626///
627/// Convenience wrapper around [`collect_route_chain`] that serializes the
628/// result into a `serde_json::Value::Array` ready for inclusion in a JSON
629/// partial response. Host servers in any language can include this directly
630/// without reimplementing the serialization.
631#[must_use]
632pub fn collect_route_chain_json(
633    protocol: &WebUIProtocol,
634    entry_id: &str,
635    request_path: &str,
636) -> Value {
637    let chain = collect_route_chain(protocol, entry_id, request_path);
638    Value::Array(chain.iter().map(RouteChainEntry::to_json).collect())
639}
640
641/// Collect the matched route chain for a request path.
642///
643/// Walks the fragment graph from `entry_id`, follows the matched route at
644/// each nesting level, and returns a chain entry per matched level.
645pub fn collect_route_chain(
646    protocol: &WebUIProtocol,
647    entry_id: &str,
648    request_path: &str,
649) -> Vec<RouteChainEntry> {
650    let mut chain = Vec::new();
651    let mut visited_fragments = HashSet::new();
652    let mut stack = vec![QueuedFragment {
653        id: entry_id.to_string(),
654        inventoryable: false,
655        route_base: "/".to_string(),
656    }];
657
658    while let Some(queued) = stack.pop() {
659        if queued.id.is_empty() || !visited_fragments.insert(queued.id.clone()) {
660            continue;
661        }
662
663        let Some(frag_list) = protocol.fragments.get(&queued.id) else {
664            continue;
665        };
666
667        let matched_route =
668            find_best_route_match(&frag_list.fragments, request_path, &queued.route_base);
669
670        for frag in &frag_list.fragments {
671            match frag.fragment.as_ref() {
672                Some(Fragment::Component(component)) => {
673                    stack.push(QueuedFragment {
674                        id: component.fragment_id.clone(),
675                        inventoryable: false,
676                        route_base: queued.route_base.clone(),
677                    });
678                }
679                Some(Fragment::Route(route_frag)) => {
680                    let is_selected = matched_route
681                        .as_ref()
682                        .is_some_and(|(best_key, _)| best_key == route_fragment_key(route_frag));
683                    if is_selected && !route_frag.fragment_id.is_empty() {
684                        if let Some((_, ref rm)) = matched_route {
685                            chain.push(RouteChainEntry {
686                                component: route_frag.fragment_id.clone(),
687                                path: route_frag.path.clone(),
688                                params: rm.params.clone(),
689                                exact: route_frag.exact,
690                            });
691
692                            let child_route_base = route_matcher::compute_route_base(
693                                request_path,
694                                rm.consumed_segments,
695                            );
696
697                            stack.push(QueuedFragment {
698                                id: route_frag.fragment_id.clone(),
699                                inventoryable: false,
700                                route_base: child_route_base.clone(),
701                            });
702
703                            // Walk nested children iteratively
704                            collect_chain_from_children(
705                                &route_frag.children,
706                                request_path,
707                                &child_route_base,
708                                &mut chain,
709                            );
710                        }
711                    }
712                }
713                _ => {}
714            }
715        }
716    }
717
718    chain
719}
720
721/// Iteratively collect chain entries from nested route children.
722fn collect_chain_from_children(
723    children: &[WebUIFragmentRoute],
724    request_path: &str,
725    route_base: &str,
726    chain: &mut Vec<RouteChainEntry>,
727) {
728    let mut pending: Vec<(&[WebUIFragmentRoute], String)> =
729        vec![(children, route_base.to_string())];
730
731    while let Some((current, base)) = pending.pop() {
732        let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
733        for (idx, child) in current.iter().enumerate() {
734            let resolved = route_matcher::resolve_route_path(&child.path, &base);
735            if let Some(m) = route_matcher::match_single_route(&resolved, request_path, child.exact)
736            {
737                let is_better = best
738                    .as_ref()
739                    .is_none_or(|(_, prev)| m.specificity > prev.specificity);
740                if is_better {
741                    best = Some((idx, m));
742                }
743            }
744        }
745
746        if let Some((idx, rm)) = best {
747            let matched = &current[idx];
748            chain.push(RouteChainEntry {
749                component: matched.fragment_id.clone(),
750                path: matched.path.clone(),
751                params: rm.params,
752                exact: matched.exact,
753            });
754            if !matched.children.is_empty() {
755                let child_base =
756                    route_matcher::compute_route_base(request_path, rm.consumed_segments);
757                pending.push((&matched.children, child_base));
758            }
759        }
760    }
761}
762
763#[cfg(test)]
764mod tests {
765    use super::*;
766    use std::collections::HashMap;
767    use webui_protocol::{FragmentList, WebUIFragment, WebUiFragmentRoute};
768
769    #[test]
770    fn test_component_bit_position_deterministic() {
771        let pos1 = component_bit_position("my-component");
772        let pos2 = component_bit_position("my-component");
773        assert_eq!(pos1, pos2);
774        assert!(pos1 < 256);
775    }
776
777    #[test]
778    fn test_has_component_present() {
779        let mut inv = vec![0u8; 32];
780        let bit = component_bit_position("test-comp");
781        let byte_idx = (bit / 8) as usize;
782        let bit_idx = bit % 8;
783        inv[byte_idx] |= 1 << bit_idx;
784        assert!(has_component(&inv, "test-comp"));
785    }
786
787    #[test]
788    fn test_has_component_absent() {
789        let inv = vec![0u8; 32];
790        assert!(!has_component(&inv, "test-comp"));
791    }
792
793    #[test]
794    fn test_parse_encode_inventory_roundtrip() {
795        let original = vec![0xABu8, 0xCD, 0xEF, 0x01];
796        let hex = encode_inventory(&original);
797        let decoded = parse_inventory(&hex);
798        assert_eq!(original, decoded);
799    }
800
801    #[test]
802    fn test_get_needed_components_empty_inventory() {
803        let mut fragments = HashMap::new();
804        fragments.insert(
805            "app-shell".to_string(),
806            FragmentList {
807                fragments: vec![WebUIFragment::component("my-card")],
808            },
809        );
810        fragments.insert(
811            "my-card".to_string(),
812            FragmentList {
813                fragments: vec![WebUIFragment::raw("card")],
814            },
815        );
816
817        let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
818        let (needed, _inv) = get_needed_components(&protocol, "app-shell", "");
819        assert!(needed.contains(&"app-shell".to_string()));
820        assert!(needed.contains(&"my-card".to_string()));
821    }
822
823    #[test]
824    fn test_get_route_templates() {
825        let mut fragments = HashMap::new();
826        fragments.insert(
827            "my-page".to_string(),
828            FragmentList {
829                fragments: vec![WebUIFragment::raw("<p>page</p>")],
830            },
831        );
832
833        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
834        protocol
835            .components
836            .entry("my-page".to_string())
837            .or_default()
838            .template = "<p>page</p>".to_string();
839
840        let (templates, _inv) = get_route_templates(&protocol, "my-page", "");
841        assert_eq!(templates.len(), 1);
842        assert_eq!(templates[0].0, "my-page");
843    }
844
845    #[test]
846    fn test_get_needed_components_skips_inventory() {
847        let mut fragments = HashMap::new();
848        fragments.insert(
849            "app-shell".to_string(),
850            FragmentList {
851                fragments: vec![WebUIFragment::component("my-card")],
852            },
853        );
854        fragments.insert(
855            "my-card".to_string(),
856            FragmentList {
857                fragments: vec![WebUIFragment::raw("card")],
858            },
859        );
860
861        let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
862
863        let (_needed, inv_hex) = get_needed_components(&protocol, "app-shell", "");
864        let (needed2, _) = get_needed_components(&protocol, "app-shell", &inv_hex);
865        assert!(needed2.is_empty());
866    }
867
868    #[test]
869    fn test_get_needed_components_skips_control_fragments() {
870        let mut fragments = HashMap::new();
871        fragments.insert(
872            "app-shell".to_string(),
873            FragmentList {
874                fragments: vec![
875                    WebUIFragment::if_cond(
876                        webui_protocol::ConditionExpr::identifier("showNav"),
877                        "if-shell",
878                    ),
879                    WebUIFragment::for_loop("item", "items", "for-items"),
880                    WebUIFragment::attribute_template("title", "attr-title"),
881                ],
882            },
883        );
884        fragments.insert(
885            "if-shell".to_string(),
886            FragmentList {
887                fragments: vec![WebUIFragment::component("mp-category-nav")],
888            },
889        );
890        fragments.insert(
891            "for-items".to_string(),
892            FragmentList {
893                fragments: vec![WebUIFragment::component("mp-product-card")],
894            },
895        );
896        fragments.insert(
897            "attr-title".to_string(),
898            FragmentList {
899                fragments: vec![WebUIFragment::raw("Products")],
900            },
901        );
902        fragments.insert(
903            "mp-category-nav".to_string(),
904            FragmentList {
905                fragments: vec![WebUIFragment::raw("<nav></nav>")],
906            },
907        );
908        fragments.insert(
909            "mp-product-card".to_string(),
910            FragmentList {
911                fragments: vec![WebUIFragment::raw("<article></article>")],
912            },
913        );
914
915        let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
916        let needed: HashSet<String> = get_needed_components(&protocol, "app-shell", "")
917            .0
918            .into_iter()
919            .collect();
920
921        assert!(needed.contains("app-shell"));
922        assert!(needed.contains("mp-category-nav"));
923        assert!(needed.contains("mp-product-card"));
924        assert!(!needed.contains("if-shell"));
925        assert!(!needed.contains("for-items"));
926        assert!(!needed.contains("attr-title"));
927    }
928
929    #[test]
930    fn test_get_needed_components_for_request_includes_shell_and_matched_route_chain() {
931        let mut fragments = HashMap::new();
932        fragments.insert(
933            "index.html".to_string(),
934            FragmentList {
935                fragments: vec![WebUIFragment::component("mp-app")],
936            },
937        );
938        fragments.insert(
939            "mp-app".to_string(),
940            FragmentList {
941                fragments: vec![
942                    WebUIFragment::component("mp-category-nav"),
943                    WebUIFragment::route_from(WebUiFragmentRoute {
944                        path: "/search/:category".into(),
945                        fragment_id: "mp-search-page".into(),
946                        exact: true,
947                        ..Default::default()
948                    }),
949                    WebUIFragment::route_from(WebUiFragmentRoute {
950                        path: "/product/:handle".into(),
951                        fragment_id: "mp-product-page".into(),
952                        exact: true,
953                        ..Default::default()
954                    }),
955                ],
956            },
957        );
958        fragments.insert(
959            "mp-category-nav".to_string(),
960            FragmentList {
961                fragments: vec![WebUIFragment::raw("<nav></nav>")],
962            },
963        );
964        fragments.insert(
965            "mp-search-page".to_string(),
966            FragmentList {
967                fragments: vec![WebUIFragment::component("mp-product-grid")],
968            },
969        );
970        fragments.insert(
971            "mp-product-grid".to_string(),
972            FragmentList {
973                fragments: vec![WebUIFragment::raw("<div>grid</div>")],
974            },
975        );
976        fragments.insert(
977            "mp-product-page".to_string(),
978            FragmentList {
979                fragments: vec![WebUIFragment::component("mp-product-detail")],
980            },
981        );
982        fragments.insert(
983            "mp-product-detail".to_string(),
984            FragmentList {
985                fragments: vec![WebUIFragment::raw("<div>detail</div>")],
986            },
987        );
988
989        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
990        protocol
991            .components
992            .entry("mp-search-page".to_string())
993            .or_default()
994            .template = "<mp-search-page></mp-search-page>".to_string();
995        protocol
996            .components
997            .entry("mp-product-page".to_string())
998            .or_default()
999            .template = "<mp-product-page></mp-product-page>".to_string();
1000
1001        let (needed, _inv) =
1002            get_needed_components_for_request(&protocol, "index.html", "/search/shirts", "");
1003        let needed: HashSet<String> = needed.into_iter().collect();
1004
1005        assert!(needed.contains("mp-app"));
1006        assert!(needed.contains("mp-category-nav"));
1007        assert!(needed.contains("mp-search-page"));
1008        assert!(needed.contains("mp-product-grid"));
1009        assert!(!needed.contains("mp-product-page"));
1010        assert!(!needed.contains("mp-product-detail"));
1011    }
1012
1013    #[test]
1014    fn test_get_needed_components_for_request_follows_nested_route_chain() {
1015        let mut fragments = HashMap::new();
1016        fragments.insert(
1017            "index.html".to_string(),
1018            FragmentList {
1019                fragments: vec![WebUIFragment::component("mp-app")],
1020            },
1021        );
1022        fragments.insert(
1023            "mp-app".to_string(),
1024            FragmentList {
1025                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1026                    path: "/account/*rest".into(),
1027                    fragment_id: "mp-account-shell".into(),
1028                    exact: false,
1029                    ..Default::default()
1030                })],
1031            },
1032        );
1033        fragments.insert(
1034            "mp-account-shell".to_string(),
1035            FragmentList {
1036                fragments: vec![
1037                    WebUIFragment::component("mp-account-nav"),
1038                    WebUIFragment::route_from(WebUiFragmentRoute {
1039                        path: "/account/profile".into(),
1040                        fragment_id: "mp-profile-page".into(),
1041                        exact: true,
1042                        ..Default::default()
1043                    }),
1044                    WebUIFragment::route_from(WebUiFragmentRoute {
1045                        path: "/account/orders/:id".into(),
1046                        fragment_id: "mp-order-page".into(),
1047                        exact: true,
1048                        ..Default::default()
1049                    }),
1050                ],
1051            },
1052        );
1053        fragments.insert(
1054            "mp-account-nav".to_string(),
1055            FragmentList {
1056                fragments: vec![WebUIFragment::raw("<nav></nav>")],
1057            },
1058        );
1059        fragments.insert(
1060            "mp-profile-page".to_string(),
1061            FragmentList {
1062                fragments: vec![WebUIFragment::raw("<profile></profile>")],
1063            },
1064        );
1065        fragments.insert(
1066            "mp-order-page".to_string(),
1067            FragmentList {
1068                fragments: vec![WebUIFragment::component("mp-order-detail")],
1069            },
1070        );
1071        fragments.insert(
1072            "mp-order-detail".to_string(),
1073            FragmentList {
1074                fragments: vec![WebUIFragment::raw("<detail></detail>")],
1075            },
1076        );
1077
1078        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1079        protocol
1080            .components
1081            .entry("mp-account-shell".to_string())
1082            .or_default()
1083            .template = "<mp-account-shell></mp-account-shell>".to_string();
1084        protocol
1085            .components
1086            .entry("mp-profile-page".to_string())
1087            .or_default()
1088            .template = "<mp-profile-page></mp-profile-page>".to_string();
1089        protocol
1090            .components
1091            .entry("mp-order-page".to_string())
1092            .or_default()
1093            .template = "<mp-order-page></mp-order-page>".to_string();
1094
1095        let (needed, _inv) =
1096            get_needed_components_for_request(&protocol, "index.html", "/account/orders/42", "");
1097        let needed: HashSet<String> = needed.into_iter().collect();
1098
1099        assert!(needed.contains("mp-app"));
1100        assert!(needed.contains("mp-account-shell"));
1101        assert!(needed.contains("mp-account-nav"));
1102        assert!(needed.contains("mp-order-page"));
1103        assert!(needed.contains("mp-order-detail"));
1104        assert!(!needed.contains("mp-profile-page"));
1105    }
1106
1107    #[test]
1108    fn test_get_needed_components_for_request_over_ships_if_and_loop_within_matched_route() {
1109        let mut fragments = HashMap::new();
1110        fragments.insert(
1111            "index.html".to_string(),
1112            FragmentList {
1113                fragments: vec![WebUIFragment::component("mp-app")],
1114            },
1115        );
1116        fragments.insert(
1117            "mp-app".to_string(),
1118            FragmentList {
1119                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1120                    path: "/search".into(),
1121                    fragment_id: "mp-search-page".into(),
1122                    exact: true,
1123                    ..Default::default()
1124                })],
1125            },
1126        );
1127        fragments.insert(
1128            "mp-search-page".to_string(),
1129            FragmentList {
1130                fragments: vec![
1131                    WebUIFragment::if_cond(
1132                        webui_protocol::ConditionExpr::identifier("showFilters"),
1133                        "if-filters",
1134                    ),
1135                    WebUIFragment::for_loop("item", "items", "item-loop"),
1136                ],
1137            },
1138        );
1139        fragments.insert(
1140            "if-filters".to_string(),
1141            FragmentList {
1142                fragments: vec![WebUIFragment::component("mp-filter-panel")],
1143            },
1144        );
1145        fragments.insert(
1146            "item-loop".to_string(),
1147            FragmentList {
1148                fragments: vec![WebUIFragment::component("mp-item-card")],
1149            },
1150        );
1151        fragments.insert(
1152            "mp-filter-panel".to_string(),
1153            FragmentList {
1154                fragments: vec![WebUIFragment::raw("<filters></filters>")],
1155            },
1156        );
1157        fragments.insert(
1158            "mp-item-card".to_string(),
1159            FragmentList {
1160                fragments: vec![WebUIFragment::raw("<item></item>")],
1161            },
1162        );
1163
1164        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1165        protocol
1166            .components
1167            .entry("mp-search-page".to_string())
1168            .or_default()
1169            .template = "<mp-search-page></mp-search-page>".to_string();
1170
1171        let (needed, _inv) =
1172            get_needed_components_for_request(&protocol, "index.html", "/search", "");
1173        let needed: HashSet<String> = needed.into_iter().collect();
1174
1175        assert!(needed.contains("mp-app"));
1176        assert!(needed.contains("mp-search-page"));
1177        assert!(needed.contains("mp-filter-panel"));
1178        assert!(needed.contains("mp-item-card"));
1179    }
1180
1181    #[test]
1182    fn test_get_needed_components_for_request_respects_inventory() {
1183        let mut fragments = HashMap::new();
1184        fragments.insert(
1185            "index.html".to_string(),
1186            FragmentList {
1187                fragments: vec![WebUIFragment::component("mp-app")],
1188            },
1189        );
1190        fragments.insert(
1191            "mp-app".to_string(),
1192            FragmentList {
1193                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1194                    path: "/search".into(),
1195                    fragment_id: "mp-search-page".into(),
1196                    exact: true,
1197                    ..Default::default()
1198                })],
1199            },
1200        );
1201        fragments.insert(
1202            "mp-search-page".to_string(),
1203            FragmentList {
1204                fragments: vec![WebUIFragment::component("mp-product-grid")],
1205            },
1206        );
1207        fragments.insert(
1208            "mp-product-grid".to_string(),
1209            FragmentList {
1210                fragments: vec![WebUIFragment::raw("<grid></grid>")],
1211            },
1212        );
1213
1214        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1215        protocol
1216            .components
1217            .entry("mp-search-page".to_string())
1218            .or_default()
1219            .template = "<mp-search-page></mp-search-page>".to_string();
1220
1221        let (_needed, inventory) =
1222            get_needed_components_for_request(&protocol, "index.html", "/search", "");
1223        let (needed_again, _) =
1224            get_needed_components_for_request(&protocol, "index.html", "/search", &inventory);
1225        assert!(needed_again.is_empty());
1226    }
1227
1228    #[test]
1229    fn test_get_route_templates_for_request_returns_only_active_route_templates() {
1230        let mut fragments = HashMap::new();
1231        fragments.insert(
1232            "index.html".to_string(),
1233            FragmentList {
1234                fragments: vec![WebUIFragment::component("mp-app")],
1235            },
1236        );
1237        fragments.insert(
1238            "mp-app".to_string(),
1239            FragmentList {
1240                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1241                    path: "/search".into(),
1242                    fragment_id: "mp-search-page".into(),
1243                    exact: true,
1244                    ..Default::default()
1245                })],
1246            },
1247        );
1248        fragments.insert(
1249            "mp-search-page".to_string(),
1250            FragmentList {
1251                fragments: vec![WebUIFragment::component("mp-product-grid")],
1252            },
1253        );
1254        fragments.insert(
1255            "mp-product-grid".to_string(),
1256            FragmentList {
1257                fragments: vec![WebUIFragment::raw("<grid></grid>")],
1258            },
1259        );
1260
1261        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1262        protocol
1263            .components
1264            .entry("mp-app".to_string())
1265            .or_default()
1266            .template = "<f-template id=app></f-template>".to_string();
1267        protocol
1268            .components
1269            .entry("mp-search-page".to_string())
1270            .or_default()
1271            .template = "<f-template id=search></f-template>".to_string();
1272        protocol
1273            .components
1274            .entry("mp-product-grid".to_string())
1275            .or_default()
1276            .template = "<f-template id=grid></f-template>".to_string();
1277
1278        let (templates, _inv) =
1279            get_route_templates_for_request(&protocol, "index.html", "/search", "");
1280
1281        assert_eq!(templates.len(), 3);
1282        assert_eq!(templates[0].0, "mp-app");
1283        assert_eq!(templates[1].0, "mp-product-grid");
1284        assert_eq!(templates[2].0, "mp-search-page");
1285    }
1286}