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