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