Skip to main content

webui_protocol/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! WebUI Protocol implementation.
5//!
6//! This crate defines the protocol used by the WebUI framework for cross-platform
7//! representation of UI components and templates. Types are generated directly
8//! from `proto/webui.proto` using prost for optimal runtime performance —
9//! no conversion layer between domain types and protobuf types.
10
11use prost::Message;
12use std::collections::HashMap;
13use std::fmt;
14use std::io;
15use thiserror::Error;
16
17/// Plugin-specific protocol helpers for framework hydration metadata.
18pub mod plugin;
19
20/// Attribute-name ↔ property-name mapping for irregular HTML attributes.
21pub mod attrs;
22
23/// Generated protobuf types from `proto/webui.proto`.
24pub mod proto {
25    include!(concat!(env!("OUT_DIR"), "/webui.rs"));
26}
27
28// Re-export all generated types at the crate root.
29pub use plugin::FastElementData;
30pub use plugin::WebUIElementData;
31pub use proto::*;
32
33// Type aliases preserving the `WebUI` naming convention.
34// prost generates `WebUi*` from the proto `WebUI*` messages.
35pub type WebUIProtocol = WebUiProtocol;
36pub type WebUIFragment = WebUiFragment;
37pub type WebUIFragmentRaw = WebUiFragmentRaw;
38pub type WebUIFragmentComponent = WebUiFragmentComponent;
39pub type WebUIFragmentFor = WebUiFragmentFor;
40pub type WebUIFragmentSignal = WebUiFragmentSignal;
41pub type WebUIFragmentIf = WebUiFragmentIf;
42pub type WebUIFragmentAttribute = WebUiFragmentAttribute;
43pub type WebUIFragmentPlugin = WebUiFragmentPlugin;
44pub type WebUIFragmentRoute = WebUiFragmentRoute;
45pub type WebUIFragmentOutlet = WebUiFragmentOutlet;
46pub type ComponentData = proto::ComponentData;
47
48/// A mapping of unique fragment identifiers to their corresponding fragment lists.
49pub type WebUIFragmentRecords = HashMap<String, FragmentList>;
50
51#[derive(Debug, Error)]
52pub enum ProtocolError {
53    #[error("IO error: {0}")]
54    Io(#[from] io::Error),
55
56    #[error("Protocol validation error: {0}")]
57    Validation(String),
58}
59
60pub type Result<T> = std::result::Result<T, ProtocolError>;
61
62// ── Display implementations ─────────────────────────────────────────────
63
64impl fmt::Display for ComparisonOperator {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        match self {
67            ComparisonOperator::GreaterThan => write!(f, ">"),
68            ComparisonOperator::LessThan => write!(f, "<"),
69            ComparisonOperator::Equal => write!(f, "=="),
70            ComparisonOperator::NotEqual => write!(f, "!="),
71            ComparisonOperator::GreaterThanOrEqual => write!(f, ">="),
72            ComparisonOperator::LessThanOrEqual => write!(f, "<="),
73            ComparisonOperator::Unspecified => write!(f, "?"),
74        }
75    }
76}
77
78impl fmt::Display for LogicalOperator {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        match self {
81            LogicalOperator::And => write!(f, "&&"),
82            LogicalOperator::Or => write!(f, "||"),
83            LogicalOperator::Unspecified => write!(f, "?"),
84        }
85    }
86}
87
88impl fmt::Display for ConditionExpr {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        match &self.expr {
91            Some(condition_expr::Expr::Identifier(id)) => write!(f, "{}", id.value),
92            Some(condition_expr::Expr::Predicate(pred)) => {
93                let op = ComparisonOperator::try_from(pred.operator)
94                    .unwrap_or(ComparisonOperator::Unspecified);
95                write!(f, "{} {} {}", pred.left, op, pred.right)
96            }
97            Some(condition_expr::Expr::Not(not)) => match &not.condition {
98                Some(inner) => write!(f, "!({})", inner),
99                None => write!(f, "!(?)"),
100            },
101            Some(condition_expr::Expr::Compound(compound)) => {
102                let op =
103                    LogicalOperator::try_from(compound.op).unwrap_or(LogicalOperator::Unspecified);
104                let left_str = compound
105                    .left
106                    .as_ref()
107                    .map(|l| l.to_string())
108                    .unwrap_or_else(|| "?".to_string());
109                let right_str = compound
110                    .right
111                    .as_ref()
112                    .map(|r| r.to_string())
113                    .unwrap_or_else(|| "?".to_string());
114                write!(f, "({} {} {})", left_str, op, right_str)
115            }
116            None => write!(f, "<empty>"),
117        }
118    }
119}
120
121// ── Convenience constructors ────────────────────────────────────────────
122
123impl WebUiFragment {
124    /// Create a raw (static content) fragment.
125    pub fn raw(value: impl Into<String>) -> Self {
126        Self {
127            fragment: Some(web_ui_fragment::Fragment::Raw(WebUiFragmentRaw {
128                value: value.into(),
129            })),
130        }
131    }
132
133    /// Create a component fragment.
134    pub fn component(fragment_id: impl Into<String>) -> Self {
135        Self {
136            fragment: Some(web_ui_fragment::Fragment::Component(
137                WebUiFragmentComponent {
138                    fragment_id: fragment_id.into(),
139                },
140            )),
141        }
142    }
143
144    /// Create a for-loop fragment.
145    pub fn for_loop(
146        item: impl Into<String>,
147        collection: impl Into<String>,
148        fragment_id: impl Into<String>,
149    ) -> Self {
150        Self {
151            fragment: Some(web_ui_fragment::Fragment::ForLoop(WebUiFragmentFor {
152                item: item.into(),
153                collection: collection.into(),
154                fragment_id: fragment_id.into(),
155            })),
156        }
157    }
158
159    /// Create a signal fragment.
160    pub fn signal(value: impl Into<String>, raw: bool) -> Self {
161        Self {
162            fragment: Some(web_ui_fragment::Fragment::Signal(WebUiFragmentSignal {
163                value: value.into(),
164                raw,
165            })),
166        }
167    }
168
169    /// Create an if-condition fragment.
170    pub fn if_cond(condition: ConditionExpr, fragment_id: impl Into<String>) -> Self {
171        Self {
172            fragment: Some(web_ui_fragment::Fragment::IfCond(WebUiFragmentIf {
173                condition: Some(condition),
174                fragment_id: fragment_id.into(),
175            })),
176        }
177    }
178
179    /// Create a simple dynamic attribute fragment (value is a single signal name).
180    pub fn attribute(name: impl Into<String>, value: impl Into<String>) -> Self {
181        Self {
182            fragment: Some(web_ui_fragment::Fragment::Attribute(
183                WebUiFragmentAttribute {
184                    name: name.into(),
185                    value: value.into(),
186                    ..Default::default()
187                },
188            )),
189        }
190    }
191
192    /// Create a template attribute fragment (mixed static + dynamic content).
193    pub fn attribute_template(name: impl Into<String>, template: impl Into<String>) -> Self {
194        Self {
195            fragment: Some(web_ui_fragment::Fragment::Attribute(
196                WebUiFragmentAttribute {
197                    name: name.into(),
198                    template: template.into(),
199                    ..Default::default()
200                },
201            )),
202        }
203    }
204
205    /// Create a complex attribute fragment (:-prefixed).
206    pub fn attribute_complex(name: impl Into<String>, value: impl Into<String>) -> Self {
207        Self {
208            fragment: Some(web_ui_fragment::Fragment::Attribute(
209                WebUiFragmentAttribute {
210                    name: name.into(),
211                    value: value.into(),
212                    complex: true,
213                    ..Default::default()
214                },
215            )),
216        }
217    }
218
219    /// Create a boolean attribute fragment (?-prefixed) with a condition tree.
220    pub fn attribute_boolean(name: impl Into<String>, condition_tree: ConditionExpr) -> Self {
221        Self {
222            fragment: Some(web_ui_fragment::Fragment::Attribute(
223                WebUiFragmentAttribute {
224                    name: name.into(),
225                    condition_tree: Some(condition_tree),
226                    ..Default::default()
227                },
228            )),
229        }
230    }
231
232    /// Create a plugin data fragment with opaque bytes.
233    /// The data is passed through to the handler plugin without interpretation.
234    pub fn plugin(data: Vec<u8>) -> Self {
235        Self {
236            fragment: Some(web_ui_fragment::Fragment::Plugin(WebUiFragmentPlugin {
237                data,
238            })),
239        }
240    }
241
242    /// Create a route fragment linking a URL path template to a fragment.
243    pub fn route(path: impl Into<String>, fragment_id: impl Into<String>) -> Self {
244        Self {
245            fragment: Some(web_ui_fragment::Fragment::Route(WebUiFragmentRoute {
246                path: path.into(),
247                fragment_id: fragment_id.into(),
248                ..Default::default()
249            })),
250        }
251    }
252
253    /// Create a route fragment from a pre-built `WebUiFragmentRoute`.
254    pub fn route_from(route: WebUiFragmentRoute) -> Self {
255        Self {
256            fragment: Some(web_ui_fragment::Fragment::Route(route)),
257        }
258    }
259
260    /// Create an outlet fragment.
261    pub fn outlet() -> Self {
262        Self {
263            fragment: Some(web_ui_fragment::Fragment::Outlet(WebUiFragmentOutlet {})),
264        }
265    }
266}
267
268impl ConditionExpr {
269    /// Create an identifier condition.
270    pub fn identifier(value: impl Into<String>) -> Self {
271        Self {
272            expr: Some(condition_expr::Expr::Identifier(IdentifierCondition {
273                value: value.into(),
274            })),
275        }
276    }
277
278    /// Create a predicate condition.
279    pub fn predicate(
280        left: impl Into<String>,
281        operator: ComparisonOperator,
282        right: impl Into<String>,
283    ) -> Self {
284        Self {
285            expr: Some(condition_expr::Expr::Predicate(Predicate {
286                left: left.into(),
287                operator: operator as i32,
288                right: right.into(),
289            })),
290        }
291    }
292
293    /// Create a negation condition.
294    pub fn negated(inner: ConditionExpr) -> Self {
295        Self {
296            expr: Some(condition_expr::Expr::Not(Box::new(NotCondition {
297                condition: Some(Box::new(inner)),
298            }))),
299        }
300    }
301
302    /// Create a compound condition.
303    pub fn compound(left: ConditionExpr, op: LogicalOperator, right: ConditionExpr) -> Self {
304        Self {
305            expr: Some(condition_expr::Expr::Compound(Box::new(
306                CompoundCondition {
307                    left: Some(Box::new(left)),
308                    op: op as i32,
309                    right: Some(Box::new(right)),
310                },
311            ))),
312        }
313    }
314}
315
316// ── Constructors ────────────────────────────────────────────────────────
317
318impl WebUiProtocol {
319    /// Create a protocol from fragment records with no CSS tokens.
320    pub fn new(fragments: WebUIFragmentRecords) -> Self {
321        Self {
322            fragments,
323            tokens: Vec::new(),
324            components: HashMap::new(),
325        }
326    }
327
328    /// Create a protocol from fragment records with CSS tokens.
329    pub fn with_tokens(fragments: WebUIFragmentRecords, tokens: Vec<String>) -> Self {
330        Self {
331            fragments,
332            tokens,
333            components: HashMap::new(),
334        }
335    }
336}
337
338// ── Serialization / deserialization / validation ────────────────────────
339
340impl WebUiProtocol {
341    /// Validate that all fragment references point to existing fragment IDs.
342    fn validate_protocol(protocol: Self) -> Result<Self> {
343        let fragments = &protocol.fragments;
344
345        let invalid_ref = fragments.iter().find_map(|(_, fragment_list)| {
346            fragment_list
347                .fragments
348                .iter()
349                .find_map(|frag| match frag.fragment.as_ref() {
350                    Some(web_ui_fragment::Fragment::Component(comp))
351                        if !fragments.contains_key(&comp.fragment_id) =>
352                    {
353                        Some(ProtocolError::Validation(format!(
354                            "Component references non-existent fragment ID: {}",
355                            comp.fragment_id
356                        )))
357                    }
358                    Some(web_ui_fragment::Fragment::ForLoop(fl))
359                        if !fragments.contains_key(&fl.fragment_id) =>
360                    {
361                        Some(ProtocolError::Validation(format!(
362                            "For loop references non-existent fragment ID: {}",
363                            fl.fragment_id
364                        )))
365                    }
366                    Some(web_ui_fragment::Fragment::IfCond(ic))
367                        if !fragments.contains_key(&ic.fragment_id) =>
368                    {
369                        Some(ProtocolError::Validation(format!(
370                            "If condition references non-existent fragment ID: {}",
371                            ic.fragment_id
372                        )))
373                    }
374                    Some(web_ui_fragment::Fragment::Attribute(attr))
375                        if !attr.template.is_empty() && !fragments.contains_key(&attr.template) =>
376                    {
377                        Some(ProtocolError::Validation(format!(
378                            "Attribute references non-existent template fragment ID: {}",
379                            attr.template
380                        )))
381                    }
382                    Some(web_ui_fragment::Fragment::Route(route)) => {
383                        if !route.fragment_id.is_empty()
384                            && !fragments.contains_key(&route.fragment_id)
385                        {
386                            return Some(ProtocolError::Validation(format!(
387                                "Route references non-existent fragment ID: {}",
388                                route.fragment_id
389                            )));
390                        }
391                        None
392                    }
393                    _ => None,
394                })
395        });
396
397        if let Some(err) = invalid_ref {
398            return Err(err);
399        }
400
401        Ok(protocol)
402    }
403
404    /// Serialize protocol to pretty JSON (for debug/inspect output only).
405    pub fn to_json_pretty(&self) -> std::result::Result<String, serde_json::Error> {
406        serde_json::to_string_pretty(self)
407    }
408
409    /// Serialize protocol to protobuf binary format.
410    pub fn to_protobuf(&self) -> Result<Vec<u8>> {
411        let len = self.encoded_len();
412        let mut buf = Vec::with_capacity(len);
413        self.encode(&mut buf)
414            .map_err(|e| ProtocolError::Validation(format!("Protobuf encode error: {e}")))?;
415        Ok(buf)
416    }
417
418    /// Deserialize protocol from protobuf binary bytes with validation.
419    pub fn from_protobuf(bytes: &[u8]) -> Result<Self> {
420        let protocol = Self::decode(bytes)
421            .map_err(|e| ProtocolError::Validation(format!("Protobuf decode error: {e}")))?;
422        Self::validate_protocol(protocol)
423    }
424
425    /// Read and deserialize a protobuf file with validation.
426    pub fn from_protobuf_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
427        let bytes = std::fs::read(path)?;
428        Self::from_protobuf(&bytes)
429    }
430
431    /// Write protocol to a protobuf file.
432    pub fn to_protobuf_file<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
433        let bytes = self.to_protobuf()?;
434        std::fs::write(path, bytes)?;
435        Ok(())
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    fn sample_protocol() -> WebUIProtocol {
444        let mut fragments = HashMap::new();
445        fragments.insert(
446            "index.html".to_string(),
447            FragmentList {
448                fragments: vec![
449                    WebUIFragment::raw("Hello, WebUI!\n"),
450                    WebUIFragment::for_loop("person", "people", "for-1"),
451                    WebUIFragment::signal("description", true),
452                    WebUIFragment::if_cond(ConditionExpr::identifier("contact"), "if-1"),
453                ],
454            },
455        );
456        fragments.insert(
457            "for-1".to_string(),
458            FragmentList {
459                fragments: vec![WebUIFragment::signal("person.name", false)],
460            },
461        );
462        fragments.insert(
463            "if-1".to_string(),
464            FragmentList {
465                fragments: vec![WebUIFragment::component("contact-card")],
466            },
467        );
468        fragments.insert(
469            "contact-card".to_string(),
470            FragmentList {
471                fragments: vec![
472                    WebUIFragment::raw("Hello, "),
473                    WebUIFragment::signal("name", false),
474                ],
475            },
476        );
477        WebUIProtocol::new(fragments)
478    }
479
480    #[test]
481    fn test_protobuf_roundtrip() {
482        let protocol = sample_protocol();
483        let bytes = protocol.to_protobuf().expect("encode failed");
484        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
485        assert_eq!(protocol, decoded);
486    }
487
488    #[test]
489    fn test_protobuf_all_fragment_types() {
490        let mut fragments = HashMap::new();
491        fragments.insert(
492            "main".to_string(),
493            FragmentList {
494                fragments: vec![
495                    WebUIFragment::raw("text"),
496                    WebUIFragment::component("comp"),
497                    WebUIFragment::for_loop("x", "xs", "loop"),
498                    WebUIFragment::signal("sig", true),
499                    WebUIFragment::if_cond(
500                        ConditionExpr::predicate("a", ComparisonOperator::GreaterThan, "1"),
501                        "cond",
502                    ),
503                ],
504            },
505        );
506        fragments.insert(
507            "comp".to_string(),
508            FragmentList {
509                fragments: vec![WebUIFragment::raw("c")],
510            },
511        );
512        fragments.insert(
513            "loop".to_string(),
514            FragmentList {
515                fragments: vec![WebUIFragment::raw("l")],
516            },
517        );
518        fragments.insert(
519            "cond".to_string(),
520            FragmentList {
521                fragments: vec![WebUIFragment::raw("i")],
522            },
523        );
524
525        let protocol = WebUIProtocol::new(fragments);
526        let bytes = protocol.to_protobuf().unwrap();
527        let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
528        assert_eq!(protocol, decoded);
529    }
530
531    #[test]
532    fn test_protobuf_all_comparison_operators() {
533        let ops = [
534            ComparisonOperator::GreaterThan,
535            ComparisonOperator::LessThan,
536            ComparisonOperator::Equal,
537            ComparisonOperator::NotEqual,
538            ComparisonOperator::GreaterThanOrEqual,
539            ComparisonOperator::LessThanOrEqual,
540        ];
541        for op in &ops {
542            let mut fragments = HashMap::new();
543            fragments.insert(
544                "main".to_string(),
545                FragmentList {
546                    fragments: vec![WebUIFragment::if_cond(
547                        ConditionExpr::predicate("a", *op, "b"),
548                        "then",
549                    )],
550                },
551            );
552            fragments.insert(
553                "then".to_string(),
554                FragmentList {
555                    fragments: vec![WebUIFragment::raw("ok")],
556                },
557            );
558            let p = WebUIProtocol::new(fragments);
559            let bytes = p.to_protobuf().unwrap();
560            let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
561            assert_eq!(p, decoded);
562        }
563    }
564
565    #[test]
566    fn test_protobuf_nested_conditions() {
567        let nested = ConditionExpr::compound(
568            ConditionExpr::predicate("user.role", ComparisonOperator::Equal, "admin"),
569            LogicalOperator::And,
570            ConditionExpr::negated(ConditionExpr::predicate(
571                "user.disabled",
572                ComparisonOperator::Equal,
573                "true",
574            )),
575        );
576
577        let mut fragments = HashMap::new();
578        fragments.insert(
579            "main".to_string(),
580            FragmentList {
581                fragments: vec![WebUIFragment::if_cond(nested, "then")],
582            },
583        );
584        fragments.insert(
585            "then".to_string(),
586            FragmentList {
587                fragments: vec![WebUIFragment::raw("ok")],
588            },
589        );
590        let p = WebUIProtocol::new(fragments);
591        let bytes = p.to_protobuf().unwrap();
592        let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
593        assert_eq!(p, decoded);
594    }
595
596    #[test]
597    fn test_protobuf_compound_or_condition() {
598        let compound = ConditionExpr::compound(
599            ConditionExpr::identifier("isAdmin"),
600            LogicalOperator::Or,
601            ConditionExpr::identifier("isEditor"),
602        );
603
604        let mut fragments = HashMap::new();
605        fragments.insert(
606            "main".to_string(),
607            FragmentList {
608                fragments: vec![WebUIFragment::if_cond(compound, "body")],
609            },
610        );
611        fragments.insert(
612            "body".to_string(),
613            FragmentList {
614                fragments: vec![WebUIFragment::raw("yes")],
615            },
616        );
617        let p = WebUIProtocol::new(fragments);
618        let bytes = p.to_protobuf().unwrap();
619        let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
620        assert_eq!(p, decoded);
621    }
622
623    #[test]
624    fn test_protobuf_invalid_bytes() {
625        let result = WebUIProtocol::from_protobuf(&[0xFF, 0xFF, 0xFF]);
626        assert!(result.is_err());
627    }
628
629    #[test]
630    fn test_protobuf_empty_bytes() {
631        let result = WebUIProtocol::from_protobuf(&[]);
632        assert!(result.is_ok());
633        assert!(result.unwrap().fragments.is_empty());
634    }
635
636    #[test]
637    fn test_protobuf_file_roundtrip() {
638        let protocol = sample_protocol();
639        let dir = std::env::temp_dir().join("webui-proto-test");
640        std::fs::create_dir_all(&dir).unwrap();
641        let path = dir.join("test.bin");
642
643        protocol.to_protobuf_file(&path).unwrap();
644        let decoded = WebUIProtocol::from_protobuf_file(&path).unwrap();
645        assert_eq!(protocol, decoded);
646
647        std::fs::remove_dir_all(&dir).ok();
648    }
649
650    #[test]
651    fn test_protobuf_validation_catches_missing_reference() {
652        let mut fragments = HashMap::new();
653        fragments.insert(
654            "main".to_string(),
655            FragmentList {
656                fragments: vec![WebUIFragment::component("does-not-exist")],
657            },
658        );
659
660        let protocol = WebUIProtocol::new(fragments);
661        let buf = protocol.to_protobuf().unwrap();
662
663        let result = WebUIProtocol::from_protobuf(&buf);
664        assert!(result.is_err());
665    }
666
667    #[test]
668    fn test_protobuf_validation_catches_missing_for_reference() {
669        let mut fragments = HashMap::new();
670        fragments.insert(
671            "main".to_string(),
672            FragmentList {
673                fragments: vec![WebUIFragment::for_loop("item", "items", "missing-for")],
674            },
675        );
676
677        let protocol = WebUIProtocol::new(fragments);
678        let buf = protocol.to_protobuf().unwrap();
679
680        let result = WebUIProtocol::from_protobuf(&buf);
681        assert!(result.is_err());
682        if let Err(ProtocolError::Validation(msg)) = result {
683            assert!(msg.contains("missing-for"));
684        }
685    }
686
687    #[test]
688    fn test_protobuf_validation_catches_missing_if_reference() {
689        let mut fragments = HashMap::new();
690        fragments.insert(
691            "main".to_string(),
692            FragmentList {
693                fragments: vec![WebUIFragment::if_cond(
694                    ConditionExpr::identifier("flag"),
695                    "missing-if",
696                )],
697            },
698        );
699
700        let protocol = WebUIProtocol::new(fragments);
701        let buf = protocol.to_protobuf().unwrap();
702
703        let result = WebUIProtocol::from_protobuf(&buf);
704        assert!(result.is_err());
705        if let Err(ProtocolError::Validation(msg)) = result {
706            assert!(msg.contains("missing-if"));
707        }
708    }
709
710    #[test]
711    fn test_protobuf_signal_default_raw_false() {
712        let mut fragments = HashMap::new();
713        fragments.insert(
714            "main".to_string(),
715            FragmentList {
716                fragments: vec![WebUIFragment::signal("name", false)],
717            },
718        );
719        let p = WebUIProtocol::new(fragments);
720        let bytes = p.to_protobuf().unwrap();
721        let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
722        let frag = &decoded.fragments["main"].fragments[0];
723        match frag.fragment.as_ref() {
724            Some(web_ui_fragment::Fragment::Signal(s)) => assert!(!s.raw),
725            _ => panic!("expected signal"),
726        }
727    }
728
729    #[test]
730    fn test_protobuf_pre_allocated_buffer() {
731        let protocol = sample_protocol();
732        let bytes = protocol.to_protobuf().unwrap();
733        assert_eq!(bytes.len(), protocol.encoded_len());
734    }
735
736    #[test]
737    fn test_protocol_new_has_empty_tokens() {
738        let protocol = WebUIProtocol::new(HashMap::new());
739        assert!(protocol.tokens.is_empty());
740        assert!(protocol.fragments.is_empty());
741    }
742
743    #[test]
744    fn test_protocol_with_tokens() {
745        let tokens = vec!["color-primary".to_string(), "spacing-m".to_string()];
746        let protocol = WebUIProtocol::with_tokens(HashMap::new(), tokens.clone());
747        assert_eq!(protocol.tokens, tokens);
748    }
749
750    #[test]
751    fn test_protobuf_route_fragment_roundtrip() {
752        let mut fragments = HashMap::new();
753        fragments.insert(
754            "main".to_string(),
755            FragmentList {
756                fragments: vec![WebUIFragment::route("/profile/:id", "profile-page")],
757            },
758        );
759        fragments.insert(
760            "profile-page".to_string(),
761            FragmentList {
762                fragments: vec![WebUIFragment::raw("<h1>Profile</h1>")],
763            },
764        );
765        let protocol = WebUIProtocol::new(fragments);
766        let bytes = protocol.to_protobuf().expect("encode failed");
767        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
768        assert_eq!(protocol, decoded);
769
770        let frag = &decoded.fragments["main"].fragments[0];
771        match frag.fragment.as_ref() {
772            Some(web_ui_fragment::Fragment::Route(r)) => {
773                assert_eq!(r.path, "/profile/:id");
774                assert_eq!(r.fragment_id, "profile-page");
775            }
776            _ => panic!("expected route fragment"),
777        }
778    }
779
780    #[test]
781    fn test_protobuf_route_fragment_all_fields() {
782        let mut fragments = HashMap::new();
783        let route_frag = WebUiFragment {
784            fragment: Some(web_ui_fragment::Fragment::Route(WebUiFragmentRoute {
785                path: "/users/:id/posts/:postId".to_string(),
786                fragment_id: "user-posts".to_string(),
787                exact: true,
788                children: Vec::new(),
789                allowed_query: "action,to,subject".to_string(),
790            })),
791        };
792        fragments.insert(
793            "main".to_string(),
794            FragmentList {
795                fragments: vec![route_frag],
796            },
797        );
798        fragments.insert(
799            "user-posts".into(),
800            FragmentList {
801                fragments: vec![WebUIFragment::raw("posts")],
802            },
803        );
804
805        let protocol = WebUIProtocol::new(fragments);
806        let bytes = protocol.to_protobuf().expect("encode failed");
807        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
808        assert_eq!(protocol, decoded);
809    }
810
811    #[test]
812    fn test_protobuf_route_validation_missing_fragment() {
813        let mut fragments = HashMap::new();
814        fragments.insert(
815            "main".to_string(),
816            FragmentList {
817                fragments: vec![WebUIFragment::route("/test", "missing-fragment")],
818            },
819        );
820        let protocol = WebUIProtocol::new(fragments);
821        let buf = protocol.to_protobuf().expect("encode failed");
822        let result = WebUIProtocol::from_protobuf(&buf);
823        assert!(result.is_err());
824        if let Err(ProtocolError::Validation(msg)) = result {
825            assert!(msg.contains("missing-fragment"));
826        }
827    }
828
829    #[test]
830    fn test_protobuf_route_no_fragment_id_roundtrip() {
831        let mut fragments = HashMap::new();
832        let route_frag = WebUiFragment {
833            fragment: Some(web_ui_fragment::Fragment::Route(WebUiFragmentRoute {
834                path: "/old-path".to_string(),
835                ..Default::default()
836            })),
837        };
838        fragments.insert(
839            "main".to_string(),
840            FragmentList {
841                fragments: vec![route_frag],
842            },
843        );
844        let protocol = WebUIProtocol::new(fragments);
845        let bytes = protocol.to_protobuf().expect("encode failed");
846        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
847        assert_eq!(protocol, decoded);
848    }
849
850    #[test]
851    fn test_protobuf_backward_compat_no_routes() {
852        // Protocol without any fragments should decode successfully
853        let protocol = WebUIProtocol::new(HashMap::new());
854        let bytes = protocol.to_protobuf().expect("encode failed");
855        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
856        assert!(decoded.fragments.is_empty());
857    }
858
859    #[test]
860    fn test_protobuf_roundtrip_with_tokens() {
861        let mut fragments = HashMap::new();
862        fragments.insert(
863            "index.html".to_string(),
864            FragmentList {
865                fragments: vec![WebUIFragment::raw("Hello")],
866            },
867        );
868        let tokens = vec!["border-radius-m".to_string(), "color-primary".to_string()];
869        let protocol = WebUIProtocol::with_tokens(fragments, tokens.clone());
870
871        let bytes = protocol.to_protobuf().expect("encode failed");
872        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
873
874        assert_eq!(decoded.tokens, tokens);
875        assert!(decoded.fragments.contains_key("index.html"));
876    }
877}