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/// Default maximum number of matches requested from the persistent index.
12pub const DEFAULT_INDEX_SEARCH_MAX_RESULTS: u32 = 50;
13
14/// How the OPC server organizes its namespace.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum NamespaceOrganization {
17    Unspecified,
18    Flat,
19    Hierarchical,
20}
21
22impl fmt::Display for NamespaceOrganization {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        f.write_str(match self {
25            Self::Unspecified => "unspecified",
26            Self::Flat => "flat",
27            Self::Hierarchical => "hierarchical",
28        })
29    }
30}
31
32/// Native or configured strategy that produced browse results.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum BrowseSource {
35    Unspecified,
36    Da3,
37    Da2,
38    Flat,
39    Derived,
40}
41
42impl fmt::Display for BrowseSource {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        f.write_str(match self {
45            Self::Unspecified => "unspecified",
46            Self::Da3 => "da3",
47            Self::Da2 => "da2",
48            Self::Flat => "flat",
49            Self::Derived => "derived",
50        })
51    }
52}
53
54/// Whether a browse node is expandable, selectable as an OPC item, or both.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum BrowseNodeKind {
57    Unspecified,
58    Branch,
59    Item,
60    BranchAndItem,
61}
62
63impl BrowseNodeKind {
64    /// Whether this node can be expanded with another browse request.
65    pub fn is_branch(self) -> bool {
66        matches!(self, Self::Branch | Self::BranchAndItem)
67    }
68
69    /// Whether this node identifies an OPC item that can be read or written.
70    pub fn is_item(self) -> bool {
71        matches!(self, Self::Item | Self::BranchAndItem)
72    }
73}
74
75impl fmt::Display for BrowseNodeKind {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        f.write_str(match self {
78            Self::Unspecified => "unspecified",
79            Self::Branch => "branch",
80            Self::Item => "item",
81            Self::BranchAndItem => "branch-and-item",
82        })
83    }
84}
85
86/// Match behavior for namespace search.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum SearchMatchMode {
89    Exact,
90    Prefix,
91    Contains,
92}
93
94impl fmt::Display for SearchMatchMode {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        f.write_str(match self {
97            Self::Exact => "exact",
98            Self::Prefix => "prefix",
99            Self::Contains => "contains",
100        })
101    }
102}
103
104/// Readiness of a gateway-owned persistent namespace index.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum SearchIndexState {
107    Unspecified,
108    NotIndexed,
109    Partial,
110    Ready,
111    Stale,
112    Refreshing,
113    Failed,
114}
115
116impl fmt::Display for SearchIndexState {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        f.write_str(match self {
119            Self::Unspecified => "unspecified",
120            Self::NotIndexed => "not-indexed",
121            Self::Partial => "partial",
122            Self::Ready => "ready",
123            Self::Stale => "stale",
124            Self::Refreshing => "refreshing",
125            Self::Failed => "failed",
126        })
127    }
128}
129
130/// Operator action applied to an active namespace-index build.
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum SearchIndexControlAction {
133    Pause,
134    Resume,
135    Cancel,
136}
137
138/// Gateway and namespace features reported for one OPC server.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct Capabilities {
141    pub application_version: String,
142    pub protocol_version: String,
143    pub max_page_size: u32,
144    pub supports_browse_sessions: bool,
145    pub supports_search: bool,
146    pub organization: NamespaceOrganization,
147    pub source: BrowseSource,
148    pub supports_indexed_search: bool,
149    pub indexed_search_protocol_version: String,
150    pub max_indexed_search_results: u32,
151    pub search_index_state: SearchIndexState,
152}
153
154/// One child returned by a browse page.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct BrowseNode {
157    /// Opaque navigation identity. Round-trip it unchanged when expanding.
158    pub node_key: String,
159    /// One local label suitable for display.
160    pub display_name: String,
161    pub kind: BrowseNodeKind,
162    /// Exact OPC DA ItemID, present only for selectable nodes.
163    pub item_id: Option<String>,
164}
165
166/// One bounded page of immediate children and its continuation metadata.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct BrowsePage {
169    pub session_id: String,
170    pub nodes: Vec<BrowseNode>,
171    pub next_page_token: Option<String>,
172    pub complete: bool,
173    pub organization: NamespaceOrganization,
174    pub source: BrowseSource,
175    pub warning: Option<String>,
176}
177
178/// Parameters for one browse-page request.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct BrowsePageRequest {
181    pub server: String,
182    pub session_id: Option<String>,
183    pub parent_node_key: Option<String>,
184    pub page_token: Option<String>,
185    pub page_size: u32,
186    pub refresh: bool,
187}
188
189impl BrowsePageRequest {
190    /// Open a new browse session and request its root page.
191    pub fn root(server: impl Into<String>, page_size: u32) -> Self {
192        Self {
193            server: server.into(),
194            session_id: None,
195            parent_node_key: None,
196            page_token: None,
197            page_size,
198            refresh: false,
199        }
200    }
201
202    /// Request the first page beneath an already-discovered branch.
203    pub fn children(
204        server: impl Into<String>,
205        session_id: impl Into<String>,
206        parent_node_key: impl Into<String>,
207        page_size: u32,
208    ) -> Self {
209        Self {
210            server: server.into(),
211            session_id: Some(session_id.into()),
212            parent_node_key: Some(parent_node_key.into()),
213            page_token: None,
214            page_size,
215            refresh: false,
216        }
217    }
218
219    /// Request the next page for a root or child browse.
220    pub fn next(
221        server: impl Into<String>,
222        session_id: impl Into<String>,
223        parent_node_key: Option<String>,
224        page_token: impl Into<String>,
225        page_size: u32,
226    ) -> Self {
227        Self {
228            server: server.into(),
229            session_id: Some(session_id.into()),
230            parent_node_key,
231            page_token: Some(page_token.into()),
232            page_size,
233            refresh: false,
234        }
235    }
236
237    /// Ask the gateway to bypass cached namespace metadata.
238    pub fn with_refresh(mut self, refresh: bool) -> Self {
239        self.refresh = refresh;
240        self
241    }
242}
243
244/// Parameters for a bounded namespace search.
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct SearchRequest {
247    pub server: String,
248    pub query: String,
249    pub match_mode: SearchMatchMode,
250    pub session_id: Option<String>,
251    pub scope_node_key: Option<String>,
252    pub max_results: u32,
253    pub include_branches: bool,
254    pub refresh: bool,
255}
256
257impl SearchRequest {
258    pub fn new(
259        server: impl Into<String>,
260        query: impl Into<String>,
261        match_mode: SearchMatchMode,
262    ) -> Self {
263        Self {
264            server: server.into(),
265            query: query.into(),
266            match_mode,
267            session_id: None,
268            scope_node_key: None,
269            max_results: DEFAULT_SEARCH_MAX_RESULTS,
270            include_branches: false,
271            refresh: false,
272        }
273    }
274}
275
276/// Parameters for one persistent-index query.
277#[derive(Debug, Clone, PartialEq, Eq)]
278pub struct SearchIndexRequest {
279    pub server: String,
280    pub query: String,
281    pub match_mode: SearchMatchMode,
282    pub max_results: u32,
283}
284
285impl SearchIndexRequest {
286    pub fn new(
287        server: impl Into<String>,
288        query: impl Into<String>,
289        match_mode: SearchMatchMode,
290    ) -> Self {
291        Self {
292            server: server.into(),
293            query: query.into(),
294            match_mode,
295            max_results: DEFAULT_INDEX_SEARCH_MAX_RESULTS,
296        }
297    }
298}
299
300/// Progress reported for a running persistent namespace inventory.
301#[derive(Debug, Clone, PartialEq)]
302pub struct IndexedSearchProgress {
303    pub branches_visited: u64,
304    pub entries_seen: u64,
305    pub unique_items: u64,
306    pub active_time_ms: u64,
307    pub paused_time_ms: u64,
308    pub items_per_second: f64,
309    pub estimated_remaining_ms: Option<u64>,
310}
311
312/// Persistent namespace-index state and build metadata.
313#[derive(Debug, Clone, PartialEq)]
314pub struct SearchIndexStatus {
315    pub server: String,
316    pub state: SearchIndexState,
317    pub configured: bool,
318    pub active_generation: u64,
319    pub entry_count: u64,
320    pub unique_item_count: u64,
321    pub started_at: Option<String>,
322    pub completed_at: Option<String>,
323    pub last_error: Option<String>,
324    pub database_bytes: u64,
325    pub organization: NamespaceOrganization,
326    pub source: BrowseSource,
327    pub progress: Option<IndexedSearchProgress>,
328}
329
330/// One selectable result from the persistent namespace index.
331#[derive(Debug, Clone, PartialEq, Eq)]
332pub struct IndexedSearchMatch {
333    pub item_id: String,
334    pub display_name: String,
335    pub kind: BrowseNodeKind,
336    pub breadcrumbs: Vec<String>,
337}
338
339/// Ranked persistent-index matches plus snapshot readiness metadata.
340#[derive(Debug, Clone, PartialEq)]
341pub struct SearchIndexResponse {
342    pub matches: Vec<IndexedSearchMatch>,
343    pub has_more: bool,
344    pub status: SearchIndexStatus,
345}
346
347/// One navigation step associated with a search match.
348#[derive(Debug, Clone, PartialEq, Eq)]
349pub struct BrowseBreadcrumb {
350    pub node_key: String,
351    pub display_name: String,
352}
353
354/// A progressively emitted namespace-search result.
355#[derive(Debug, Clone, PartialEq, Eq)]
356pub struct SearchMatch {
357    pub node: BrowseNode,
358    pub breadcrumbs: Vec<BrowseBreadcrumb>,
359}
360
361/// Progress emitted while a namespace search is still running.
362#[derive(Debug, Clone, PartialEq, Eq)]
363pub struct SearchProgress {
364    pub visited_nodes: u32,
365    pub matches: u32,
366    pub partial: bool,
367}
368
369/// Terminal search metadata.
370#[derive(Debug, Clone, PartialEq, Eq)]
371pub struct SearchCompleted {
372    pub complete: bool,
373    pub cancelled: bool,
374    pub truncated: bool,
375    pub warning: Option<String>,
376}
377
378/// One event from the gateway's search stream.
379#[derive(Debug, Clone, PartialEq, Eq)]
380pub enum SearchEvent {
381    Match(SearchMatch),
382    Progress(SearchProgress),
383    Completed(SearchCompleted),
384}
385
386/// A single tag's value returned by [`crate::Client::read`].
387#[derive(Debug, Clone, PartialEq, Eq)]
388pub struct TagValue {
389    pub tag_id: String,
390    pub value: String,
391    pub quality: String,
392    pub timestamp: String,
393}
394
395/// The result of a single [`crate::Client::write`] call.
396#[derive(Debug, Clone, PartialEq, Eq)]
397pub struct WriteResult {
398    pub tag_id: String,
399    pub success: bool,
400    pub error: Option<String>,
401}
402
403/// A tag value to write, parsed from a raw string via [`parse_value`].
404#[derive(Debug, Clone, PartialEq)]
405pub enum Value {
406    String(String),
407    Int(i32),
408    Float(f64),
409    Bool(bool),
410}
411
412/// Parse a raw string into bool, integer, float, or string form.
413pub fn parse_value(raw: &str) -> Value {
414    if let Ok(b) = raw.parse::<bool>() {
415        return Value::Bool(b);
416    }
417    if let Ok(i) = raw.parse::<i32>() {
418        return Value::Int(i);
419    }
420    if let Ok(f) = raw.parse::<f64>() {
421        return Value::Float(f);
422    }
423    Value::String(raw.to_string())
424}
425
426fn invalid_enum(field: &str, value: i32) -> Error {
427    Error::Protocol(format!("gateway returned unknown {field} value {value}"))
428}
429
430fn organization(value: i32) -> Result<NamespaceOrganization> {
431    match proto::NamespaceOrganization::try_from(value)
432        .map_err(|_| invalid_enum("namespace organization", value))?
433    {
434        proto::NamespaceOrganization::Unspecified => Ok(NamespaceOrganization::Unspecified),
435        proto::NamespaceOrganization::Flat => Ok(NamespaceOrganization::Flat),
436        proto::NamespaceOrganization::Hierarchical => Ok(NamespaceOrganization::Hierarchical),
437    }
438}
439
440fn source(value: i32) -> Result<BrowseSource> {
441    match proto::BrowseSource::try_from(value).map_err(|_| invalid_enum("browse source", value))? {
442        proto::BrowseSource::Unspecified => Ok(BrowseSource::Unspecified),
443        proto::BrowseSource::Da3 => Ok(BrowseSource::Da3),
444        proto::BrowseSource::Da2 => Ok(BrowseSource::Da2),
445        proto::BrowseSource::Flat => Ok(BrowseSource::Flat),
446        proto::BrowseSource::Derived => Ok(BrowseSource::Derived),
447    }
448}
449
450fn node_kind(value: i32) -> Result<BrowseNodeKind> {
451    match proto::BrowseNodeKind::try_from(value)
452        .map_err(|_| invalid_enum("browse node kind", value))?
453    {
454        proto::BrowseNodeKind::Unspecified => Ok(BrowseNodeKind::Unspecified),
455        proto::BrowseNodeKind::Branch => Ok(BrowseNodeKind::Branch),
456        proto::BrowseNodeKind::Item => Ok(BrowseNodeKind::Item),
457        proto::BrowseNodeKind::BranchAndItem => Ok(BrowseNodeKind::BranchAndItem),
458    }
459}
460
461fn search_index_state(value: i32) -> Result<SearchIndexState> {
462    match proto::SearchIndexState::try_from(value)
463        .map_err(|_| invalid_enum("search index state", value))?
464    {
465        proto::SearchIndexState::Unspecified => Ok(SearchIndexState::Unspecified),
466        proto::SearchIndexState::NotIndexed => Ok(SearchIndexState::NotIndexed),
467        proto::SearchIndexState::Partial => Ok(SearchIndexState::Partial),
468        proto::SearchIndexState::Ready => Ok(SearchIndexState::Ready),
469        proto::SearchIndexState::Stale => Ok(SearchIndexState::Stale),
470        proto::SearchIndexState::Refreshing => Ok(SearchIndexState::Refreshing),
471        proto::SearchIndexState::Failed => Ok(SearchIndexState::Failed),
472    }
473}
474
475impl TryFrom<proto::GetCapabilitiesResponse> for Capabilities {
476    type Error = Error;
477
478    fn try_from(value: proto::GetCapabilitiesResponse) -> Result<Self> {
479        Ok(Self {
480            application_version: value.application_version,
481            protocol_version: value.protocol_version,
482            max_page_size: value.max_page_size,
483            supports_browse_sessions: value.supports_browse_sessions,
484            supports_search: value.supports_search,
485            organization: organization(value.organization)?,
486            source: source(value.source)?,
487            supports_indexed_search: value.supports_indexed_search,
488            indexed_search_protocol_version: value.indexed_search_protocol_version,
489            max_indexed_search_results: value.max_indexed_search_results,
490            search_index_state: search_index_state(value.search_index_state)?,
491        })
492    }
493}
494
495impl TryFrom<proto::BrowseNode> for BrowseNode {
496    type Error = Error;
497
498    fn try_from(value: proto::BrowseNode) -> Result<Self> {
499        let kind = node_kind(value.kind)?;
500        if kind.is_item() && value.item_id.is_none() {
501            return Err(Error::Protocol(
502                "gateway returned a selectable browse node without an ItemID".into(),
503            ));
504        }
505        if !kind.is_item() && value.item_id.is_some() {
506            return Err(Error::Protocol(
507                "gateway returned an ItemID for a non-selectable browse node".into(),
508            ));
509        }
510        Ok(Self {
511            node_key: value.node_key,
512            display_name: value.display_name,
513            kind,
514            item_id: value.item_id,
515        })
516    }
517}
518
519impl TryFrom<proto::BrowsePage> for BrowsePage {
520    type Error = Error;
521
522    fn try_from(value: proto::BrowsePage) -> Result<Self> {
523        if value.complete && value.next_page_token.is_some() {
524            return Err(Error::Protocol(
525                "gateway returned a complete browse page with a continuation token".into(),
526            ));
527        }
528        if !value.complete && value.next_page_token.is_none() {
529            return Err(Error::Protocol(
530                "gateway returned an incomplete browse page without a continuation token".into(),
531            ));
532        }
533        Ok(Self {
534            session_id: value.session_id,
535            nodes: value
536                .nodes
537                .into_iter()
538                .map(BrowseNode::try_from)
539                .collect::<Result<_>>()?,
540            next_page_token: value.next_page_token,
541            complete: value.complete,
542            organization: organization(value.organization)?,
543            source: source(value.source)?,
544            warning: value.warning,
545        })
546    }
547}
548
549impl From<BrowsePageRequest> for proto::BrowseRequest {
550    fn from(value: BrowsePageRequest) -> Self {
551        Self {
552            server: value.server,
553            session_id: value.session_id,
554            parent_node_key: value.parent_node_key,
555            page_token: value.page_token,
556            page_size: value.page_size,
557            refresh: value.refresh,
558        }
559    }
560}
561
562impl From<SearchRequest> for proto::SearchRequest {
563    fn from(value: SearchRequest) -> Self {
564        let match_mode = match value.match_mode {
565            SearchMatchMode::Exact => proto::SearchMatchMode::Exact,
566            SearchMatchMode::Prefix => proto::SearchMatchMode::Prefix,
567            SearchMatchMode::Contains => proto::SearchMatchMode::Contains,
568        };
569        Self {
570            server: value.server,
571            query: value.query,
572            match_mode: match_mode as i32,
573            session_id: value.session_id,
574            scope_node_key: value.scope_node_key,
575            max_results: value.max_results,
576            include_branches: value.include_branches,
577            refresh: value.refresh,
578        }
579    }
580}
581
582impl From<SearchIndexRequest> for proto::SearchIndexRequest {
583    fn from(value: SearchIndexRequest) -> Self {
584        let match_mode = match value.match_mode {
585            SearchMatchMode::Exact => proto::SearchMatchMode::Exact,
586            SearchMatchMode::Prefix => proto::SearchMatchMode::Prefix,
587            SearchMatchMode::Contains => proto::SearchMatchMode::Contains,
588        };
589        Self {
590            server: value.server,
591            query: value.query,
592            match_mode: match_mode as i32,
593            max_results: value.max_results,
594        }
595    }
596}
597
598impl From<SearchIndexControlAction> for proto::SearchIndexControlAction {
599    fn from(value: SearchIndexControlAction) -> Self {
600        match value {
601            SearchIndexControlAction::Pause => Self::Pause,
602            SearchIndexControlAction::Resume => Self::Resume,
603            SearchIndexControlAction::Cancel => Self::Cancel,
604        }
605    }
606}
607
608impl From<proto::IndexedSearchProgress> for IndexedSearchProgress {
609    fn from(value: proto::IndexedSearchProgress) -> Self {
610        Self {
611            branches_visited: value.branches_visited,
612            entries_seen: value.entries_seen,
613            unique_items: value.unique_items,
614            active_time_ms: value.active_time_ms,
615            paused_time_ms: value.paused_time_ms,
616            items_per_second: value.items_per_second,
617            estimated_remaining_ms: value.estimated_remaining_ms,
618        }
619    }
620}
621
622impl TryFrom<proto::SearchIndexStatus> for SearchIndexStatus {
623    type Error = Error;
624
625    fn try_from(value: proto::SearchIndexStatus) -> Result<Self> {
626        Ok(Self {
627            server: value.server,
628            state: search_index_state(value.state)?,
629            configured: value.configured,
630            active_generation: value.active_generation,
631            entry_count: value.entry_count,
632            unique_item_count: value.unique_item_count,
633            started_at: value.started_at,
634            completed_at: value.completed_at,
635            last_error: value.last_error,
636            database_bytes: value.database_bytes,
637            organization: organization(value.organization)?,
638            source: source(value.source)?,
639            progress: value.progress.map(Into::into),
640        })
641    }
642}
643
644impl TryFrom<proto::IndexedSearchMatch> for IndexedSearchMatch {
645    type Error = Error;
646
647    fn try_from(value: proto::IndexedSearchMatch) -> Result<Self> {
648        let kind = node_kind(value.kind)?;
649        if !kind.is_item() {
650            return Err(Error::Protocol(
651                "gateway returned a non-selectable indexed search match".into(),
652            ));
653        }
654        if value.item_id.is_empty() {
655            return Err(Error::Protocol(
656                "gateway returned an indexed search match without an ItemID".into(),
657            ));
658        }
659        Ok(Self {
660            item_id: value.item_id,
661            display_name: value.display_name,
662            kind,
663            breadcrumbs: value.breadcrumbs,
664        })
665    }
666}
667
668impl TryFrom<proto::SearchIndexResponse> for SearchIndexResponse {
669    type Error = Error;
670
671    fn try_from(value: proto::SearchIndexResponse) -> Result<Self> {
672        Ok(Self {
673            matches: value
674                .matches
675                .into_iter()
676                .map(IndexedSearchMatch::try_from)
677                .collect::<Result<_>>()?,
678            has_more: value.has_more,
679            status: value
680                .status
681                .ok_or_else(|| {
682                    Error::Protocol("gateway returned indexed search results without status".into())
683                })?
684                .try_into()?,
685        })
686    }
687}
688
689impl TryFrom<proto::SearchEvent> for SearchEvent {
690    type Error = Error;
691
692    fn try_from(value: proto::SearchEvent) -> Result<Self> {
693        match value.event {
694            Some(proto::search_event::Event::Match(found)) => {
695                let node = found.node.ok_or_else(|| {
696                    Error::Protocol("gateway returned a search match without a node".into())
697                })?;
698                Ok(Self::Match(SearchMatch {
699                    node: node.try_into()?,
700                    breadcrumbs: found
701                        .breadcrumbs
702                        .into_iter()
703                        .map(|part| BrowseBreadcrumb {
704                            node_key: part.node_key,
705                            display_name: part.display_name,
706                        })
707                        .collect(),
708                }))
709            }
710            Some(proto::search_event::Event::Progress(progress)) => {
711                Ok(Self::Progress(SearchProgress {
712                    visited_nodes: progress.visited_nodes,
713                    matches: progress.matches,
714                    partial: progress.partial,
715                }))
716            }
717            Some(proto::search_event::Event::Completed(completed)) => {
718                Ok(Self::Completed(SearchCompleted {
719                    complete: completed.complete,
720                    cancelled: completed.cancelled,
721                    truncated: completed.truncated,
722                    warning: completed.warning,
723                }))
724            }
725            None => Err(Error::Protocol(
726                "gateway returned an empty search event".into(),
727            )),
728        }
729    }
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735
736    #[test]
737    fn value_parsing_covers_all_variants() {
738        assert!(matches!(parse_value("true"), Value::Bool(true)));
739        assert!(matches!(parse_value("false"), Value::Bool(false)));
740        assert!(matches!(parse_value("42"), Value::Int(42)));
741        assert!(matches!(parse_value("-1"), Value::Int(-1)));
742        assert!(matches!(parse_value("9.5"), Value::Float(v) if v == 9.5));
743        assert!(matches!(parse_value("hello"), Value::String(v) if v == "hello"));
744    }
745
746    #[test]
747    fn enum_display_and_node_predicates_are_stable() {
748        assert_eq!(
749            NamespaceOrganization::Unspecified.to_string(),
750            "unspecified"
751        );
752        assert_eq!(NamespaceOrganization::Flat.to_string(), "flat");
753        assert_eq!(
754            NamespaceOrganization::Hierarchical.to_string(),
755            "hierarchical"
756        );
757        assert_eq!(BrowseSource::Unspecified.to_string(), "unspecified");
758        assert_eq!(BrowseSource::Da3.to_string(), "da3");
759        assert_eq!(BrowseSource::Da2.to_string(), "da2");
760        assert_eq!(BrowseSource::Flat.to_string(), "flat");
761        assert_eq!(BrowseSource::Derived.to_string(), "derived");
762        assert_eq!(BrowseNodeKind::Unspecified.to_string(), "unspecified");
763        assert_eq!(BrowseNodeKind::Branch.to_string(), "branch");
764        assert_eq!(BrowseNodeKind::Item.to_string(), "item");
765        assert_eq!(BrowseNodeKind::BranchAndItem.to_string(), "branch-and-item");
766        assert_eq!(SearchMatchMode::Exact.to_string(), "exact");
767        assert_eq!(SearchMatchMode::Prefix.to_string(), "prefix");
768        assert_eq!(SearchMatchMode::Contains.to_string(), "contains");
769        assert_eq!(SearchIndexState::Unspecified.to_string(), "unspecified");
770        assert_eq!(SearchIndexState::NotIndexed.to_string(), "not-indexed");
771        assert_eq!(SearchIndexState::Partial.to_string(), "partial");
772        assert_eq!(SearchIndexState::Ready.to_string(), "ready");
773        assert_eq!(SearchIndexState::Stale.to_string(), "stale");
774        assert_eq!(SearchIndexState::Refreshing.to_string(), "refreshing");
775        assert_eq!(SearchIndexState::Failed.to_string(), "failed");
776        assert!(BrowseNodeKind::Branch.is_branch());
777        assert!(!BrowseNodeKind::Branch.is_item());
778        assert!(BrowseNodeKind::Item.is_item());
779        assert!(!BrowseNodeKind::Item.is_branch());
780        assert!(BrowseNodeKind::BranchAndItem.is_branch());
781        assert!(BrowseNodeKind::BranchAndItem.is_item());
782        assert!(!BrowseNodeKind::Unspecified.is_branch());
783        assert!(!BrowseNodeKind::Unspecified.is_item());
784    }
785
786    #[test]
787    fn browse_request_builders_map_all_fields() {
788        let root = BrowsePageRequest::root("S", 20).with_refresh(true);
789        assert_eq!(root.server, "S");
790        assert_eq!(root.page_size, 20);
791        assert!(root.refresh);
792
793        let children = BrowsePageRequest::children("S", "session", "node", 30);
794        assert_eq!(children.session_id.as_deref(), Some("session"));
795        assert_eq!(children.parent_node_key.as_deref(), Some("node"));
796
797        let next = BrowsePageRequest::next("S", "session", Some("node".into()), "token", 40);
798        let proto: proto::BrowseRequest = next.into();
799        assert_eq!(proto.page_token.as_deref(), Some("token"));
800        assert_eq!(proto.page_size, 40);
801    }
802
803    #[test]
804    fn search_request_defaults_and_mapping_are_typed() {
805        for (mode, expected) in [
806            (SearchMatchMode::Exact, proto::SearchMatchMode::Exact),
807            (SearchMatchMode::Prefix, proto::SearchMatchMode::Prefix),
808            (SearchMatchMode::Contains, proto::SearchMatchMode::Contains),
809        ] {
810            let request = SearchRequest::new("S", "query", mode);
811            assert_eq!(request.max_results, DEFAULT_SEARCH_MAX_RESULTS);
812            let mapped: proto::SearchRequest = request.into();
813            assert_eq!(mapped.match_mode, expected as i32);
814        }
815    }
816
817    #[test]
818    fn indexed_search_request_and_controls_map_all_variants() {
819        for (mode, expected) in [
820            (SearchMatchMode::Exact, proto::SearchMatchMode::Exact),
821            (SearchMatchMode::Prefix, proto::SearchMatchMode::Prefix),
822            (SearchMatchMode::Contains, proto::SearchMatchMode::Contains),
823        ] {
824            let request = SearchIndexRequest::new("S", "query", mode);
825            assert_eq!(request.max_results, DEFAULT_INDEX_SEARCH_MAX_RESULTS);
826            let mapped: proto::SearchIndexRequest = request.into();
827            assert_eq!(mapped.match_mode, expected as i32);
828        }
829        for (action, expected) in [
830            (
831                SearchIndexControlAction::Pause,
832                proto::SearchIndexControlAction::Pause,
833            ),
834            (
835                SearchIndexControlAction::Resume,
836                proto::SearchIndexControlAction::Resume,
837            ),
838            (
839                SearchIndexControlAction::Cancel,
840                proto::SearchIndexControlAction::Cancel,
841            ),
842        ] {
843            assert_eq!(proto::SearchIndexControlAction::from(action), expected);
844        }
845    }
846
847    #[test]
848    fn invalid_and_inconsistent_proto_values_are_rejected() {
849        assert_eq!(
850            organization(proto::NamespaceOrganization::Unspecified as i32).unwrap(),
851            NamespaceOrganization::Unspecified
852        );
853        assert_eq!(
854            organization(proto::NamespaceOrganization::Flat as i32).unwrap(),
855            NamespaceOrganization::Flat
856        );
857        assert_eq!(
858            organization(proto::NamespaceOrganization::Hierarchical as i32).unwrap(),
859            NamespaceOrganization::Hierarchical
860        );
861        assert_eq!(
862            source(proto::BrowseSource::Unspecified as i32).unwrap(),
863            BrowseSource::Unspecified
864        );
865        assert_eq!(
866            source(proto::BrowseSource::Da3 as i32).unwrap(),
867            BrowseSource::Da3
868        );
869        assert_eq!(
870            source(proto::BrowseSource::Da2 as i32).unwrap(),
871            BrowseSource::Da2
872        );
873        assert_eq!(
874            source(proto::BrowseSource::Flat as i32).unwrap(),
875            BrowseSource::Flat
876        );
877        assert_eq!(
878            source(proto::BrowseSource::Derived as i32).unwrap(),
879            BrowseSource::Derived
880        );
881        assert_eq!(
882            node_kind(proto::BrowseNodeKind::Unspecified as i32).unwrap(),
883            BrowseNodeKind::Unspecified
884        );
885        assert_eq!(
886            node_kind(proto::BrowseNodeKind::Branch as i32).unwrap(),
887            BrowseNodeKind::Branch
888        );
889        assert_eq!(
890            node_kind(proto::BrowseNodeKind::Item as i32).unwrap(),
891            BrowseNodeKind::Item
892        );
893        assert_eq!(
894            node_kind(proto::BrowseNodeKind::BranchAndItem as i32).unwrap(),
895            BrowseNodeKind::BranchAndItem
896        );
897        assert!(matches!(organization(99), Err(Error::Protocol(_))));
898        assert!(matches!(source(99), Err(Error::Protocol(_))));
899        assert!(matches!(node_kind(99), Err(Error::Protocol(_))));
900        for (proto_state, state) in [
901            (
902                proto::SearchIndexState::Unspecified,
903                SearchIndexState::Unspecified,
904            ),
905            (
906                proto::SearchIndexState::NotIndexed,
907                SearchIndexState::NotIndexed,
908            ),
909            (proto::SearchIndexState::Partial, SearchIndexState::Partial),
910            (proto::SearchIndexState::Ready, SearchIndexState::Ready),
911            (proto::SearchIndexState::Stale, SearchIndexState::Stale),
912            (
913                proto::SearchIndexState::Refreshing,
914                SearchIndexState::Refreshing,
915            ),
916            (proto::SearchIndexState::Failed, SearchIndexState::Failed),
917        ] {
918            assert_eq!(search_index_state(proto_state as i32).unwrap(), state);
919        }
920        assert!(matches!(search_index_state(99), Err(Error::Protocol(_))));
921
922        let missing_item_id = proto::BrowseNode {
923            kind: proto::BrowseNodeKind::Item as i32,
924            ..Default::default()
925        };
926        assert!(matches!(
927            BrowseNode::try_from(missing_item_id),
928            Err(Error::Protocol(_))
929        ));
930        let unexpected_item_id = proto::BrowseNode {
931            kind: proto::BrowseNodeKind::Branch as i32,
932            item_id: Some("not-valid".into()),
933            ..Default::default()
934        };
935        assert!(matches!(
936            BrowseNode::try_from(unexpected_item_id),
937            Err(Error::Protocol(_))
938        ));
939
940        let complete_with_token = proto::BrowsePage {
941            complete: true,
942            next_page_token: Some("token".into()),
943            ..Default::default()
944        };
945        assert!(matches!(
946            BrowsePage::try_from(complete_with_token),
947            Err(Error::Protocol(_))
948        ));
949
950        let incomplete_without_token = proto::BrowsePage::default();
951        assert!(matches!(
952            BrowsePage::try_from(incomplete_without_token),
953            Err(Error::Protocol(_))
954        ));
955    }
956
957    #[test]
958    fn search_event_conversion_covers_every_event() {
959        let found = proto::SearchEvent {
960            event: Some(proto::search_event::Event::Match(proto::SearchMatch {
961                node: Some(proto::BrowseNode {
962                    node_key: "n".into(),
963                    display_name: "PV".into(),
964                    kind: proto::BrowseNodeKind::Item as i32,
965                    item_id: Some("FCS!TAG.PV".into()),
966                }),
967                breadcrumbs: vec![proto::BrowseBreadcrumb {
968                    node_key: "root".into(),
969                    display_name: "FCS".into(),
970                }],
971            })),
972        };
973        assert!(matches!(
974            SearchEvent::try_from(found).unwrap(),
975            SearchEvent::Match(_)
976        ));
977
978        let progress = proto::SearchEvent {
979            event: Some(proto::search_event::Event::Progress(
980                proto::SearchProgress {
981                    visited_nodes: 10,
982                    matches: 2,
983                    partial: true,
984                },
985            )),
986        };
987        assert!(matches!(
988            SearchEvent::try_from(progress).unwrap(),
989            SearchEvent::Progress(_)
990        ));
991
992        let completed = proto::SearchEvent {
993            event: Some(proto::search_event::Event::Completed(
994                proto::SearchCompleted {
995                    complete: true,
996                    cancelled: false,
997                    truncated: false,
998                    warning: None,
999                },
1000            )),
1001        };
1002        assert!(matches!(
1003            SearchEvent::try_from(completed).unwrap(),
1004            SearchEvent::Completed(_)
1005        ));
1006
1007        assert!(matches!(
1008            SearchEvent::try_from(proto::SearchEvent::default()),
1009            Err(Error::Protocol(_))
1010        ));
1011        let missing_node = proto::SearchEvent {
1012            event: Some(proto::search_event::Event::Match(
1013                proto::SearchMatch::default(),
1014            )),
1015        };
1016        assert!(matches!(
1017            SearchEvent::try_from(missing_node),
1018            Err(Error::Protocol(_))
1019        ));
1020    }
1021
1022    #[test]
1023    fn indexed_search_response_preserves_identity_and_status() {
1024        let response = proto::SearchIndexResponse {
1025            matches: vec![proto::IndexedSearchMatch {
1026                item_id: "FCS0201!204FI00510.PV".into(),
1027                display_name: "PV".into(),
1028                kind: proto::BrowseNodeKind::BranchAndItem as i32,
1029                breadcrumbs: vec!["FCS0201".into(), "204FI00510".into()],
1030            }],
1031            has_more: true,
1032            status: Some(proto::SearchIndexStatus {
1033                server: "Yokogawa.CSHIS_OPC.1".into(),
1034                state: proto::SearchIndexState::Refreshing as i32,
1035                configured: true,
1036                active_generation: 7,
1037                entry_count: 100_001,
1038                unique_item_count: 100_000,
1039                started_at: Some("start".into()),
1040                completed_at: Some("complete".into()),
1041                last_error: Some("prior error".into()),
1042                database_bytes: 4096,
1043                organization: proto::NamespaceOrganization::Hierarchical as i32,
1044                source: proto::BrowseSource::Da2 as i32,
1045                progress: Some(proto::IndexedSearchProgress {
1046                    branches_visited: 10,
1047                    entries_seen: 20,
1048                    unique_items: 19,
1049                    active_time_ms: 30,
1050                    paused_time_ms: 40,
1051                    items_per_second: 12.5,
1052                    estimated_remaining_ms: Some(50),
1053                }),
1054            }),
1055        };
1056        let typed = SearchIndexResponse::try_from(response).unwrap();
1057        assert_eq!(typed.matches[0].item_id, "FCS0201!204FI00510.PV");
1058        assert_eq!(typed.matches[0].kind, BrowseNodeKind::BranchAndItem);
1059        assert_eq!(typed.status.state, SearchIndexState::Refreshing);
1060        assert_eq!(
1061            typed
1062                .status
1063                .progress
1064                .as_ref()
1065                .unwrap()
1066                .estimated_remaining_ms,
1067            Some(50)
1068        );
1069        assert!(typed.has_more);
1070
1071        let item = IndexedSearchMatch::try_from(proto::IndexedSearchMatch {
1072            kind: proto::BrowseNodeKind::Item as i32,
1073            item_id: "id".into(),
1074            ..Default::default()
1075        })
1076        .unwrap();
1077        assert_eq!(item.kind, BrowseNodeKind::Item);
1078        assert!(matches!(
1079            IndexedSearchMatch::try_from(proto::IndexedSearchMatch {
1080                kind: proto::BrowseNodeKind::Item as i32,
1081                ..Default::default()
1082            }),
1083            Err(Error::Protocol(_))
1084        ));
1085
1086        assert!(matches!(
1087            IndexedSearchMatch::try_from(proto::IndexedSearchMatch {
1088                kind: proto::BrowseNodeKind::Branch as i32,
1089                ..Default::default()
1090            }),
1091            Err(Error::Protocol(_))
1092        ));
1093        assert!(matches!(
1094            SearchIndexResponse::try_from(proto::SearchIndexResponse::default()),
1095            Err(Error::Protocol(_))
1096        ));
1097    }
1098}