Skip to main content

opcda_bridge/
types.rs

1//! Plain data types returned by [`crate::Client`]'s methods.
2
3use crate::{Error, Result};
4use opcda_bridge_proto::bridge as proto;
5use std::fmt;
6
7/// Default number of children requested for one browse page.
8pub const DEFAULT_PAGE_SIZE: u32 = 200;
9/// Default maximum number of matches requested by a search.
10pub const DEFAULT_SEARCH_MAX_RESULTS: u32 = 200;
11
12/// How the OPC server organizes its namespace.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum NamespaceOrganization {
15    Unspecified,
16    Flat,
17    Hierarchical,
18}
19
20impl fmt::Display for NamespaceOrganization {
21    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22        f.write_str(match self {
23            Self::Unspecified => "unspecified",
24            Self::Flat => "flat",
25            Self::Hierarchical => "hierarchical",
26        })
27    }
28}
29
30/// Native or configured strategy that produced browse results.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum BrowseSource {
33    Unspecified,
34    Da3,
35    Da2,
36    Flat,
37    Derived,
38}
39
40impl fmt::Display for BrowseSource {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        f.write_str(match self {
43            Self::Unspecified => "unspecified",
44            Self::Da3 => "da3",
45            Self::Da2 => "da2",
46            Self::Flat => "flat",
47            Self::Derived => "derived",
48        })
49    }
50}
51
52/// Whether a browse node is expandable, selectable as an OPC item, or both.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum BrowseNodeKind {
55    Unspecified,
56    Branch,
57    Item,
58    BranchAndItem,
59}
60
61impl BrowseNodeKind {
62    /// Whether this node can be expanded with another browse request.
63    pub fn is_branch(self) -> bool {
64        matches!(self, Self::Branch | Self::BranchAndItem)
65    }
66
67    /// Whether this node identifies an OPC item that can be read or written.
68    pub fn is_item(self) -> bool {
69        matches!(self, Self::Item | Self::BranchAndItem)
70    }
71}
72
73impl fmt::Display for BrowseNodeKind {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        f.write_str(match self {
76            Self::Unspecified => "unspecified",
77            Self::Branch => "branch",
78            Self::Item => "item",
79            Self::BranchAndItem => "branch-and-item",
80        })
81    }
82}
83
84/// Match behavior for namespace search.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum SearchMatchMode {
87    Exact,
88    Prefix,
89    Contains,
90}
91
92impl fmt::Display for SearchMatchMode {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        f.write_str(match self {
95            Self::Exact => "exact",
96            Self::Prefix => "prefix",
97            Self::Contains => "contains",
98        })
99    }
100}
101
102/// Gateway and namespace features reported for one OPC server.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct Capabilities {
105    pub application_version: String,
106    pub protocol_version: String,
107    pub max_page_size: u32,
108    pub supports_browse_sessions: bool,
109    pub supports_search: bool,
110    pub organization: NamespaceOrganization,
111    pub source: BrowseSource,
112}
113
114/// One child returned by a browse page.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct BrowseNode {
117    /// Opaque navigation identity. Round-trip it unchanged when expanding.
118    pub node_key: String,
119    /// One local label suitable for display.
120    pub display_name: String,
121    pub kind: BrowseNodeKind,
122    /// Exact OPC DA ItemID, present only for selectable nodes.
123    pub item_id: Option<String>,
124}
125
126/// One bounded page of immediate children and its continuation metadata.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct BrowsePage {
129    pub session_id: String,
130    pub nodes: Vec<BrowseNode>,
131    pub next_page_token: Option<String>,
132    pub complete: bool,
133    pub organization: NamespaceOrganization,
134    pub source: BrowseSource,
135    pub warning: Option<String>,
136}
137
138/// Parameters for one browse-page request.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct BrowsePageRequest {
141    pub server: String,
142    pub session_id: Option<String>,
143    pub parent_node_key: Option<String>,
144    pub page_token: Option<String>,
145    pub page_size: u32,
146    pub refresh: bool,
147}
148
149impl BrowsePageRequest {
150    /// Open a new browse session and request its root page.
151    pub fn root(server: impl Into<String>, page_size: u32) -> Self {
152        Self {
153            server: server.into(),
154            session_id: None,
155            parent_node_key: None,
156            page_token: None,
157            page_size,
158            refresh: false,
159        }
160    }
161
162    /// Request the first page beneath an already-discovered branch.
163    pub fn children(
164        server: impl Into<String>,
165        session_id: impl Into<String>,
166        parent_node_key: impl Into<String>,
167        page_size: u32,
168    ) -> Self {
169        Self {
170            server: server.into(),
171            session_id: Some(session_id.into()),
172            parent_node_key: Some(parent_node_key.into()),
173            page_token: None,
174            page_size,
175            refresh: false,
176        }
177    }
178
179    /// Request the next page for a root or child browse.
180    pub fn next(
181        server: impl Into<String>,
182        session_id: impl Into<String>,
183        parent_node_key: Option<String>,
184        page_token: impl Into<String>,
185        page_size: u32,
186    ) -> Self {
187        Self {
188            server: server.into(),
189            session_id: Some(session_id.into()),
190            parent_node_key,
191            page_token: Some(page_token.into()),
192            page_size,
193            refresh: false,
194        }
195    }
196
197    /// Ask the gateway to bypass cached namespace metadata.
198    pub fn with_refresh(mut self, refresh: bool) -> Self {
199        self.refresh = refresh;
200        self
201    }
202}
203
204/// Parameters for a bounded namespace search.
205#[derive(Debug, Clone, PartialEq, Eq)]
206pub struct SearchRequest {
207    pub server: String,
208    pub query: String,
209    pub match_mode: SearchMatchMode,
210    pub session_id: Option<String>,
211    pub scope_node_key: Option<String>,
212    pub max_results: u32,
213    pub include_branches: bool,
214    pub refresh: bool,
215}
216
217impl SearchRequest {
218    pub fn new(
219        server: impl Into<String>,
220        query: impl Into<String>,
221        match_mode: SearchMatchMode,
222    ) -> Self {
223        Self {
224            server: server.into(),
225            query: query.into(),
226            match_mode,
227            session_id: None,
228            scope_node_key: None,
229            max_results: DEFAULT_SEARCH_MAX_RESULTS,
230            include_branches: false,
231            refresh: false,
232        }
233    }
234}
235
236/// One navigation step associated with a search match.
237#[derive(Debug, Clone, PartialEq, Eq)]
238pub struct BrowseBreadcrumb {
239    pub node_key: String,
240    pub display_name: String,
241}
242
243/// A progressively emitted namespace-search result.
244#[derive(Debug, Clone, PartialEq, Eq)]
245pub struct SearchMatch {
246    pub node: BrowseNode,
247    pub breadcrumbs: Vec<BrowseBreadcrumb>,
248}
249
250/// Progress emitted while a namespace search is still running.
251#[derive(Debug, Clone, PartialEq, Eq)]
252pub struct SearchProgress {
253    pub visited_nodes: u32,
254    pub matches: u32,
255    pub partial: bool,
256}
257
258/// Terminal search metadata.
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct SearchCompleted {
261    pub complete: bool,
262    pub cancelled: bool,
263    pub truncated: bool,
264    pub warning: Option<String>,
265}
266
267/// One event from the gateway's search stream.
268#[derive(Debug, Clone, PartialEq, Eq)]
269pub enum SearchEvent {
270    Match(SearchMatch),
271    Progress(SearchProgress),
272    Completed(SearchCompleted),
273}
274
275/// A single tag's value returned by [`crate::Client::read`].
276#[derive(Debug, Clone, PartialEq, Eq)]
277pub struct TagValue {
278    pub tag_id: String,
279    pub value: String,
280    pub quality: String,
281    pub timestamp: String,
282}
283
284/// The result of a single [`crate::Client::write`] call.
285#[derive(Debug, Clone, PartialEq, Eq)]
286pub struct WriteResult {
287    pub tag_id: String,
288    pub success: bool,
289    pub error: Option<String>,
290}
291
292/// A tag value to write, parsed from a raw string via [`parse_value`].
293#[derive(Debug, Clone, PartialEq)]
294pub enum Value {
295    String(String),
296    Int(i32),
297    Float(f64),
298    Bool(bool),
299}
300
301/// Parse a raw string into bool, integer, float, or string form.
302pub fn parse_value(raw: &str) -> Value {
303    if let Ok(b) = raw.parse::<bool>() {
304        return Value::Bool(b);
305    }
306    if let Ok(i) = raw.parse::<i32>() {
307        return Value::Int(i);
308    }
309    if let Ok(f) = raw.parse::<f64>() {
310        return Value::Float(f);
311    }
312    Value::String(raw.to_string())
313}
314
315fn invalid_enum(field: &str, value: i32) -> Error {
316    Error::Protocol(format!("gateway returned unknown {field} value {value}"))
317}
318
319fn organization(value: i32) -> Result<NamespaceOrganization> {
320    match proto::NamespaceOrganization::try_from(value)
321        .map_err(|_| invalid_enum("namespace organization", value))?
322    {
323        proto::NamespaceOrganization::Unspecified => Ok(NamespaceOrganization::Unspecified),
324        proto::NamespaceOrganization::Flat => Ok(NamespaceOrganization::Flat),
325        proto::NamespaceOrganization::Hierarchical => Ok(NamespaceOrganization::Hierarchical),
326    }
327}
328
329fn source(value: i32) -> Result<BrowseSource> {
330    match proto::BrowseSource::try_from(value).map_err(|_| invalid_enum("browse source", value))? {
331        proto::BrowseSource::Unspecified => Ok(BrowseSource::Unspecified),
332        proto::BrowseSource::Da3 => Ok(BrowseSource::Da3),
333        proto::BrowseSource::Da2 => Ok(BrowseSource::Da2),
334        proto::BrowseSource::Flat => Ok(BrowseSource::Flat),
335        proto::BrowseSource::Derived => Ok(BrowseSource::Derived),
336    }
337}
338
339fn node_kind(value: i32) -> Result<BrowseNodeKind> {
340    match proto::BrowseNodeKind::try_from(value)
341        .map_err(|_| invalid_enum("browse node kind", value))?
342    {
343        proto::BrowseNodeKind::Unspecified => Ok(BrowseNodeKind::Unspecified),
344        proto::BrowseNodeKind::Branch => Ok(BrowseNodeKind::Branch),
345        proto::BrowseNodeKind::Item => Ok(BrowseNodeKind::Item),
346        proto::BrowseNodeKind::BranchAndItem => Ok(BrowseNodeKind::BranchAndItem),
347    }
348}
349
350impl TryFrom<proto::GetCapabilitiesResponse> for Capabilities {
351    type Error = Error;
352
353    fn try_from(value: proto::GetCapabilitiesResponse) -> Result<Self> {
354        Ok(Self {
355            application_version: value.application_version,
356            protocol_version: value.protocol_version,
357            max_page_size: value.max_page_size,
358            supports_browse_sessions: value.supports_browse_sessions,
359            supports_search: value.supports_search,
360            organization: organization(value.organization)?,
361            source: source(value.source)?,
362        })
363    }
364}
365
366impl TryFrom<proto::BrowseNode> for BrowseNode {
367    type Error = Error;
368
369    fn try_from(value: proto::BrowseNode) -> Result<Self> {
370        let kind = node_kind(value.kind)?;
371        if kind.is_item() && value.item_id.is_none() {
372            return Err(Error::Protocol(
373                "gateway returned a selectable browse node without an ItemID".into(),
374            ));
375        }
376        if !kind.is_item() && value.item_id.is_some() {
377            return Err(Error::Protocol(
378                "gateway returned an ItemID for a non-selectable browse node".into(),
379            ));
380        }
381        Ok(Self {
382            node_key: value.node_key,
383            display_name: value.display_name,
384            kind,
385            item_id: value.item_id,
386        })
387    }
388}
389
390impl TryFrom<proto::BrowsePage> for BrowsePage {
391    type Error = Error;
392
393    fn try_from(value: proto::BrowsePage) -> Result<Self> {
394        if value.complete && value.next_page_token.is_some() {
395            return Err(Error::Protocol(
396                "gateway returned a complete browse page with a continuation token".into(),
397            ));
398        }
399        if !value.complete && value.next_page_token.is_none() {
400            return Err(Error::Protocol(
401                "gateway returned an incomplete browse page without a continuation token".into(),
402            ));
403        }
404        Ok(Self {
405            session_id: value.session_id,
406            nodes: value
407                .nodes
408                .into_iter()
409                .map(BrowseNode::try_from)
410                .collect::<Result<_>>()?,
411            next_page_token: value.next_page_token,
412            complete: value.complete,
413            organization: organization(value.organization)?,
414            source: source(value.source)?,
415            warning: value.warning,
416        })
417    }
418}
419
420impl From<BrowsePageRequest> for proto::BrowseRequest {
421    fn from(value: BrowsePageRequest) -> Self {
422        Self {
423            server: value.server,
424            session_id: value.session_id,
425            parent_node_key: value.parent_node_key,
426            page_token: value.page_token,
427            page_size: value.page_size,
428            refresh: value.refresh,
429        }
430    }
431}
432
433impl From<SearchRequest> for proto::SearchRequest {
434    fn from(value: SearchRequest) -> Self {
435        let match_mode = match value.match_mode {
436            SearchMatchMode::Exact => proto::SearchMatchMode::Exact,
437            SearchMatchMode::Prefix => proto::SearchMatchMode::Prefix,
438            SearchMatchMode::Contains => proto::SearchMatchMode::Contains,
439        };
440        Self {
441            server: value.server,
442            query: value.query,
443            match_mode: match_mode as i32,
444            session_id: value.session_id,
445            scope_node_key: value.scope_node_key,
446            max_results: value.max_results,
447            include_branches: value.include_branches,
448            refresh: value.refresh,
449        }
450    }
451}
452
453impl TryFrom<proto::SearchEvent> for SearchEvent {
454    type Error = Error;
455
456    fn try_from(value: proto::SearchEvent) -> Result<Self> {
457        match value.event {
458            Some(proto::search_event::Event::Match(found)) => {
459                let node = found.node.ok_or_else(|| {
460                    Error::Protocol("gateway returned a search match without a node".into())
461                })?;
462                Ok(Self::Match(SearchMatch {
463                    node: node.try_into()?,
464                    breadcrumbs: found
465                        .breadcrumbs
466                        .into_iter()
467                        .map(|part| BrowseBreadcrumb {
468                            node_key: part.node_key,
469                            display_name: part.display_name,
470                        })
471                        .collect(),
472                }))
473            }
474            Some(proto::search_event::Event::Progress(progress)) => {
475                Ok(Self::Progress(SearchProgress {
476                    visited_nodes: progress.visited_nodes,
477                    matches: progress.matches,
478                    partial: progress.partial,
479                }))
480            }
481            Some(proto::search_event::Event::Completed(completed)) => {
482                Ok(Self::Completed(SearchCompleted {
483                    complete: completed.complete,
484                    cancelled: completed.cancelled,
485                    truncated: completed.truncated,
486                    warning: completed.warning,
487                }))
488            }
489            None => Err(Error::Protocol(
490                "gateway returned an empty search event".into(),
491            )),
492        }
493    }
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    #[test]
501    fn value_parsing_covers_all_variants() {
502        assert!(matches!(parse_value("true"), Value::Bool(true)));
503        assert!(matches!(parse_value("false"), Value::Bool(false)));
504        assert!(matches!(parse_value("42"), Value::Int(42)));
505        assert!(matches!(parse_value("-1"), Value::Int(-1)));
506        assert!(matches!(parse_value("9.5"), Value::Float(v) if v == 9.5));
507        assert!(matches!(parse_value("hello"), Value::String(v) if v == "hello"));
508    }
509
510    #[test]
511    fn enum_display_and_node_predicates_are_stable() {
512        assert_eq!(
513            NamespaceOrganization::Unspecified.to_string(),
514            "unspecified"
515        );
516        assert_eq!(NamespaceOrganization::Flat.to_string(), "flat");
517        assert_eq!(
518            NamespaceOrganization::Hierarchical.to_string(),
519            "hierarchical"
520        );
521        assert_eq!(BrowseSource::Unspecified.to_string(), "unspecified");
522        assert_eq!(BrowseSource::Da3.to_string(), "da3");
523        assert_eq!(BrowseSource::Da2.to_string(), "da2");
524        assert_eq!(BrowseSource::Flat.to_string(), "flat");
525        assert_eq!(BrowseSource::Derived.to_string(), "derived");
526        assert_eq!(BrowseNodeKind::Unspecified.to_string(), "unspecified");
527        assert_eq!(BrowseNodeKind::Branch.to_string(), "branch");
528        assert_eq!(BrowseNodeKind::Item.to_string(), "item");
529        assert_eq!(BrowseNodeKind::BranchAndItem.to_string(), "branch-and-item");
530        assert_eq!(SearchMatchMode::Exact.to_string(), "exact");
531        assert_eq!(SearchMatchMode::Prefix.to_string(), "prefix");
532        assert_eq!(SearchMatchMode::Contains.to_string(), "contains");
533        assert!(BrowseNodeKind::Branch.is_branch());
534        assert!(!BrowseNodeKind::Branch.is_item());
535        assert!(BrowseNodeKind::Item.is_item());
536        assert!(!BrowseNodeKind::Item.is_branch());
537        assert!(BrowseNodeKind::BranchAndItem.is_branch());
538        assert!(BrowseNodeKind::BranchAndItem.is_item());
539        assert!(!BrowseNodeKind::Unspecified.is_branch());
540        assert!(!BrowseNodeKind::Unspecified.is_item());
541    }
542
543    #[test]
544    fn browse_request_builders_map_all_fields() {
545        let root = BrowsePageRequest::root("S", 20).with_refresh(true);
546        assert_eq!(root.server, "S");
547        assert_eq!(root.page_size, 20);
548        assert!(root.refresh);
549
550        let children = BrowsePageRequest::children("S", "session", "node", 30);
551        assert_eq!(children.session_id.as_deref(), Some("session"));
552        assert_eq!(children.parent_node_key.as_deref(), Some("node"));
553
554        let next = BrowsePageRequest::next("S", "session", Some("node".into()), "token", 40);
555        let proto: proto::BrowseRequest = next.into();
556        assert_eq!(proto.page_token.as_deref(), Some("token"));
557        assert_eq!(proto.page_size, 40);
558    }
559
560    #[test]
561    fn search_request_defaults_and_mapping_are_typed() {
562        for (mode, expected) in [
563            (SearchMatchMode::Exact, proto::SearchMatchMode::Exact),
564            (SearchMatchMode::Prefix, proto::SearchMatchMode::Prefix),
565            (SearchMatchMode::Contains, proto::SearchMatchMode::Contains),
566        ] {
567            let request = SearchRequest::new("S", "query", mode);
568            assert_eq!(request.max_results, DEFAULT_SEARCH_MAX_RESULTS);
569            let mapped: proto::SearchRequest = request.into();
570            assert_eq!(mapped.match_mode, expected as i32);
571        }
572    }
573
574    #[test]
575    fn invalid_and_inconsistent_proto_values_are_rejected() {
576        assert_eq!(
577            organization(proto::NamespaceOrganization::Unspecified as i32).unwrap(),
578            NamespaceOrganization::Unspecified
579        );
580        assert_eq!(
581            organization(proto::NamespaceOrganization::Flat as i32).unwrap(),
582            NamespaceOrganization::Flat
583        );
584        assert_eq!(
585            organization(proto::NamespaceOrganization::Hierarchical as i32).unwrap(),
586            NamespaceOrganization::Hierarchical
587        );
588        assert_eq!(
589            source(proto::BrowseSource::Unspecified as i32).unwrap(),
590            BrowseSource::Unspecified
591        );
592        assert_eq!(
593            source(proto::BrowseSource::Da3 as i32).unwrap(),
594            BrowseSource::Da3
595        );
596        assert_eq!(
597            source(proto::BrowseSource::Da2 as i32).unwrap(),
598            BrowseSource::Da2
599        );
600        assert_eq!(
601            source(proto::BrowseSource::Flat as i32).unwrap(),
602            BrowseSource::Flat
603        );
604        assert_eq!(
605            source(proto::BrowseSource::Derived as i32).unwrap(),
606            BrowseSource::Derived
607        );
608        assert_eq!(
609            node_kind(proto::BrowseNodeKind::Unspecified as i32).unwrap(),
610            BrowseNodeKind::Unspecified
611        );
612        assert_eq!(
613            node_kind(proto::BrowseNodeKind::Branch as i32).unwrap(),
614            BrowseNodeKind::Branch
615        );
616        assert_eq!(
617            node_kind(proto::BrowseNodeKind::Item as i32).unwrap(),
618            BrowseNodeKind::Item
619        );
620        assert_eq!(
621            node_kind(proto::BrowseNodeKind::BranchAndItem as i32).unwrap(),
622            BrowseNodeKind::BranchAndItem
623        );
624        assert!(matches!(organization(99), Err(Error::Protocol(_))));
625        assert!(matches!(source(99), Err(Error::Protocol(_))));
626        assert!(matches!(node_kind(99), Err(Error::Protocol(_))));
627
628        let missing_item_id = proto::BrowseNode {
629            kind: proto::BrowseNodeKind::Item as i32,
630            ..Default::default()
631        };
632        assert!(matches!(
633            BrowseNode::try_from(missing_item_id),
634            Err(Error::Protocol(_))
635        ));
636        let unexpected_item_id = proto::BrowseNode {
637            kind: proto::BrowseNodeKind::Branch as i32,
638            item_id: Some("not-valid".into()),
639            ..Default::default()
640        };
641        assert!(matches!(
642            BrowseNode::try_from(unexpected_item_id),
643            Err(Error::Protocol(_))
644        ));
645
646        let complete_with_token = proto::BrowsePage {
647            complete: true,
648            next_page_token: Some("token".into()),
649            ..Default::default()
650        };
651        assert!(matches!(
652            BrowsePage::try_from(complete_with_token),
653            Err(Error::Protocol(_))
654        ));
655
656        let incomplete_without_token = proto::BrowsePage::default();
657        assert!(matches!(
658            BrowsePage::try_from(incomplete_without_token),
659            Err(Error::Protocol(_))
660        ));
661    }
662
663    #[test]
664    fn search_event_conversion_covers_every_event() {
665        let found = proto::SearchEvent {
666            event: Some(proto::search_event::Event::Match(proto::SearchMatch {
667                node: Some(proto::BrowseNode {
668                    node_key: "n".into(),
669                    display_name: "PV".into(),
670                    kind: proto::BrowseNodeKind::Item as i32,
671                    item_id: Some("FCS!TAG.PV".into()),
672                }),
673                breadcrumbs: vec![proto::BrowseBreadcrumb {
674                    node_key: "root".into(),
675                    display_name: "FCS".into(),
676                }],
677            })),
678        };
679        assert!(matches!(
680            SearchEvent::try_from(found).unwrap(),
681            SearchEvent::Match(_)
682        ));
683
684        let progress = proto::SearchEvent {
685            event: Some(proto::search_event::Event::Progress(
686                proto::SearchProgress {
687                    visited_nodes: 10,
688                    matches: 2,
689                    partial: true,
690                },
691            )),
692        };
693        assert!(matches!(
694            SearchEvent::try_from(progress).unwrap(),
695            SearchEvent::Progress(_)
696        ));
697
698        let completed = proto::SearchEvent {
699            event: Some(proto::search_event::Event::Completed(
700                proto::SearchCompleted {
701                    complete: true,
702                    cancelled: false,
703                    truncated: false,
704                    warning: None,
705                },
706            )),
707        };
708        assert!(matches!(
709            SearchEvent::try_from(completed).unwrap(),
710            SearchEvent::Completed(_)
711        ));
712
713        assert!(matches!(
714            SearchEvent::try_from(proto::SearchEvent::default()),
715            Err(Error::Protocol(_))
716        ));
717        let missing_node = proto::SearchEvent {
718            event: Some(proto::search_event::Event::Match(
719                proto::SearchMatch::default(),
720            )),
721        };
722        assert!(matches!(
723            SearchEvent::try_from(missing_node),
724            Err(Error::Protocol(_))
725        ));
726    }
727}