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 semantic value returned by [`crate::Client::read`].
387///
388/// For an OPC DA `VT_BSTR`, `value` contains the exact BSTR contents. The
389/// bridge does not add or remove quote characters.
390#[derive(Debug, Clone, PartialEq, Eq)]
391pub struct TagValue {
392    pub tag_id: String,
393    pub value: String,
394    pub quality: String,
395    pub timestamp: String,
396}
397
398/// The result of a single [`crate::Client::write`] call.
399#[derive(Debug, Clone, PartialEq, Eq)]
400pub struct WriteResult {
401    pub tag_id: String,
402    pub success: bool,
403    pub error: Option<String>,
404}
405
406/// A tag value to write, parsed from a raw string via [`parse_value`].
407#[derive(Debug, Clone, PartialEq)]
408pub enum Value {
409    String(String),
410    Int(i32),
411    Float(f64),
412    Bool(bool),
413}
414
415/// Parse a raw string into bool, integer, float, or string form.
416pub fn parse_value(raw: &str) -> Value {
417    if let Ok(b) = raw.parse::<bool>() {
418        return Value::Bool(b);
419    }
420    if let Ok(i) = raw.parse::<i32>() {
421        return Value::Int(i);
422    }
423    if let Ok(f) = raw.parse::<f64>() {
424        return Value::Float(f);
425    }
426    Value::String(raw.to_string())
427}
428
429fn invalid_enum(field: &str, value: i32) -> Error {
430    Error::Protocol(format!("gateway returned unknown {field} value {value}"))
431}
432
433fn organization(value: i32) -> Result<NamespaceOrganization> {
434    match proto::NamespaceOrganization::try_from(value)
435        .map_err(|_| invalid_enum("namespace organization", value))?
436    {
437        proto::NamespaceOrganization::Unspecified => Ok(NamespaceOrganization::Unspecified),
438        proto::NamespaceOrganization::Flat => Ok(NamespaceOrganization::Flat),
439        proto::NamespaceOrganization::Hierarchical => Ok(NamespaceOrganization::Hierarchical),
440    }
441}
442
443fn source(value: i32) -> Result<BrowseSource> {
444    match proto::BrowseSource::try_from(value).map_err(|_| invalid_enum("browse source", value))? {
445        proto::BrowseSource::Unspecified => Ok(BrowseSource::Unspecified),
446        proto::BrowseSource::Da3 => Ok(BrowseSource::Da3),
447        proto::BrowseSource::Da2 => Ok(BrowseSource::Da2),
448        proto::BrowseSource::Flat => Ok(BrowseSource::Flat),
449        proto::BrowseSource::Derived => Ok(BrowseSource::Derived),
450    }
451}
452
453fn node_kind(value: i32) -> Result<BrowseNodeKind> {
454    match proto::BrowseNodeKind::try_from(value)
455        .map_err(|_| invalid_enum("browse node kind", value))?
456    {
457        proto::BrowseNodeKind::Unspecified => Ok(BrowseNodeKind::Unspecified),
458        proto::BrowseNodeKind::Branch => Ok(BrowseNodeKind::Branch),
459        proto::BrowseNodeKind::Item => Ok(BrowseNodeKind::Item),
460        proto::BrowseNodeKind::BranchAndItem => Ok(BrowseNodeKind::BranchAndItem),
461    }
462}
463
464fn search_index_state(value: i32) -> Result<SearchIndexState> {
465    match proto::SearchIndexState::try_from(value)
466        .map_err(|_| invalid_enum("search index state", value))?
467    {
468        proto::SearchIndexState::Unspecified => Ok(SearchIndexState::Unspecified),
469        proto::SearchIndexState::NotIndexed => Ok(SearchIndexState::NotIndexed),
470        proto::SearchIndexState::Partial => Ok(SearchIndexState::Partial),
471        proto::SearchIndexState::Ready => Ok(SearchIndexState::Ready),
472        proto::SearchIndexState::Stale => Ok(SearchIndexState::Stale),
473        proto::SearchIndexState::Refreshing => Ok(SearchIndexState::Refreshing),
474        proto::SearchIndexState::Failed => Ok(SearchIndexState::Failed),
475    }
476}
477
478impl TryFrom<proto::GetCapabilitiesResponse> for Capabilities {
479    type Error = Error;
480
481    fn try_from(value: proto::GetCapabilitiesResponse) -> Result<Self> {
482        Ok(Self {
483            application_version: value.application_version,
484            protocol_version: value.protocol_version,
485            max_page_size: value.max_page_size,
486            supports_browse_sessions: value.supports_browse_sessions,
487            supports_search: value.supports_search,
488            organization: organization(value.organization)?,
489            source: source(value.source)?,
490            supports_indexed_search: value.supports_indexed_search,
491            indexed_search_protocol_version: value.indexed_search_protocol_version,
492            max_indexed_search_results: value.max_indexed_search_results,
493            search_index_state: search_index_state(value.search_index_state)?,
494        })
495    }
496}
497
498impl TryFrom<proto::BrowseNode> for BrowseNode {
499    type Error = Error;
500
501    fn try_from(value: proto::BrowseNode) -> Result<Self> {
502        let kind = node_kind(value.kind)?;
503        if kind.is_item() && value.item_id.is_none() {
504            return Err(Error::Protocol(
505                "gateway returned a selectable browse node without an ItemID".into(),
506            ));
507        }
508        if !kind.is_item() && value.item_id.is_some() {
509            return Err(Error::Protocol(
510                "gateway returned an ItemID for a non-selectable browse node".into(),
511            ));
512        }
513        Ok(Self {
514            node_key: value.node_key,
515            display_name: value.display_name,
516            kind,
517            item_id: value.item_id,
518        })
519    }
520}
521
522impl TryFrom<proto::BrowsePage> for BrowsePage {
523    type Error = Error;
524
525    fn try_from(value: proto::BrowsePage) -> Result<Self> {
526        if value.complete && value.next_page_token.is_some() {
527            return Err(Error::Protocol(
528                "gateway returned a complete browse page with a continuation token".into(),
529            ));
530        }
531        if !value.complete && value.next_page_token.is_none() {
532            return Err(Error::Protocol(
533                "gateway returned an incomplete browse page without a continuation token".into(),
534            ));
535        }
536        Ok(Self {
537            session_id: value.session_id,
538            nodes: value
539                .nodes
540                .into_iter()
541                .map(BrowseNode::try_from)
542                .collect::<Result<_>>()?,
543            next_page_token: value.next_page_token,
544            complete: value.complete,
545            organization: organization(value.organization)?,
546            source: source(value.source)?,
547            warning: value.warning,
548        })
549    }
550}
551
552impl From<BrowsePageRequest> for proto::BrowseRequest {
553    fn from(value: BrowsePageRequest) -> Self {
554        Self {
555            server: value.server,
556            session_id: value.session_id,
557            parent_node_key: value.parent_node_key,
558            page_token: value.page_token,
559            page_size: value.page_size,
560            refresh: value.refresh,
561        }
562    }
563}
564
565impl From<SearchRequest> for proto::SearchRequest {
566    fn from(value: SearchRequest) -> Self {
567        let match_mode = match value.match_mode {
568            SearchMatchMode::Exact => proto::SearchMatchMode::Exact,
569            SearchMatchMode::Prefix => proto::SearchMatchMode::Prefix,
570            SearchMatchMode::Contains => proto::SearchMatchMode::Contains,
571        };
572        Self {
573            server: value.server,
574            query: value.query,
575            match_mode: match_mode as i32,
576            session_id: value.session_id,
577            scope_node_key: value.scope_node_key,
578            max_results: value.max_results,
579            include_branches: value.include_branches,
580            refresh: value.refresh,
581        }
582    }
583}
584
585impl From<SearchIndexRequest> for proto::SearchIndexRequest {
586    fn from(value: SearchIndexRequest) -> Self {
587        let match_mode = match value.match_mode {
588            SearchMatchMode::Exact => proto::SearchMatchMode::Exact,
589            SearchMatchMode::Prefix => proto::SearchMatchMode::Prefix,
590            SearchMatchMode::Contains => proto::SearchMatchMode::Contains,
591        };
592        Self {
593            server: value.server,
594            query: value.query,
595            match_mode: match_mode as i32,
596            max_results: value.max_results,
597        }
598    }
599}
600
601impl From<SearchIndexControlAction> for proto::SearchIndexControlAction {
602    fn from(value: SearchIndexControlAction) -> Self {
603        match value {
604            SearchIndexControlAction::Pause => Self::Pause,
605            SearchIndexControlAction::Resume => Self::Resume,
606            SearchIndexControlAction::Cancel => Self::Cancel,
607        }
608    }
609}
610
611impl From<proto::IndexedSearchProgress> for IndexedSearchProgress {
612    fn from(value: proto::IndexedSearchProgress) -> Self {
613        Self {
614            branches_visited: value.branches_visited,
615            entries_seen: value.entries_seen,
616            unique_items: value.unique_items,
617            active_time_ms: value.active_time_ms,
618            paused_time_ms: value.paused_time_ms,
619            items_per_second: value.items_per_second,
620            estimated_remaining_ms: value.estimated_remaining_ms,
621        }
622    }
623}
624
625impl TryFrom<proto::SearchIndexStatus> for SearchIndexStatus {
626    type Error = Error;
627
628    fn try_from(value: proto::SearchIndexStatus) -> Result<Self> {
629        Ok(Self {
630            server: value.server,
631            state: search_index_state(value.state)?,
632            configured: value.configured,
633            active_generation: value.active_generation,
634            entry_count: value.entry_count,
635            unique_item_count: value.unique_item_count,
636            started_at: value.started_at,
637            completed_at: value.completed_at,
638            last_error: value.last_error,
639            database_bytes: value.database_bytes,
640            organization: organization(value.organization)?,
641            source: source(value.source)?,
642            progress: value.progress.map(Into::into),
643        })
644    }
645}
646
647impl TryFrom<proto::IndexedSearchMatch> for IndexedSearchMatch {
648    type Error = Error;
649
650    fn try_from(value: proto::IndexedSearchMatch) -> Result<Self> {
651        let kind = node_kind(value.kind)?;
652        if !kind.is_item() {
653            return Err(Error::Protocol(
654                "gateway returned a non-selectable indexed search match".into(),
655            ));
656        }
657        if value.item_id.is_empty() {
658            return Err(Error::Protocol(
659                "gateway returned an indexed search match without an ItemID".into(),
660            ));
661        }
662        Ok(Self {
663            item_id: value.item_id,
664            display_name: value.display_name,
665            kind,
666            breadcrumbs: value.breadcrumbs,
667        })
668    }
669}
670
671impl TryFrom<proto::SearchIndexResponse> for SearchIndexResponse {
672    type Error = Error;
673
674    fn try_from(value: proto::SearchIndexResponse) -> Result<Self> {
675        Ok(Self {
676            matches: value
677                .matches
678                .into_iter()
679                .map(IndexedSearchMatch::try_from)
680                .collect::<Result<_>>()?,
681            has_more: value.has_more,
682            status: value
683                .status
684                .ok_or_else(|| {
685                    Error::Protocol("gateway returned indexed search results without status".into())
686                })?
687                .try_into()?,
688        })
689    }
690}
691
692impl TryFrom<proto::SearchEvent> for SearchEvent {
693    type Error = Error;
694
695    fn try_from(value: proto::SearchEvent) -> Result<Self> {
696        match value.event {
697            Some(proto::search_event::Event::Match(found)) => {
698                let node = found.node.ok_or_else(|| {
699                    Error::Protocol("gateway returned a search match without a node".into())
700                })?;
701                Ok(Self::Match(SearchMatch {
702                    node: node.try_into()?,
703                    breadcrumbs: found
704                        .breadcrumbs
705                        .into_iter()
706                        .map(|part| BrowseBreadcrumb {
707                            node_key: part.node_key,
708                            display_name: part.display_name,
709                        })
710                        .collect(),
711                }))
712            }
713            Some(proto::search_event::Event::Progress(progress)) => {
714                Ok(Self::Progress(SearchProgress {
715                    visited_nodes: progress.visited_nodes,
716                    matches: progress.matches,
717                    partial: progress.partial,
718                }))
719            }
720            Some(proto::search_event::Event::Completed(completed)) => {
721                Ok(Self::Completed(SearchCompleted {
722                    complete: completed.complete,
723                    cancelled: completed.cancelled,
724                    truncated: completed.truncated,
725                    warning: completed.warning,
726                }))
727            }
728            None => Err(Error::Protocol(
729                "gateway returned an empty search event".into(),
730            )),
731        }
732    }
733}
734
735#[cfg(test)]
736mod tests {
737    use super::*;
738
739    #[test]
740    fn value_parsing_covers_all_variants() {
741        assert!(matches!(parse_value("true"), Value::Bool(true)));
742        assert!(matches!(parse_value("false"), Value::Bool(false)));
743        assert!(matches!(parse_value("42"), Value::Int(42)));
744        assert!(matches!(parse_value("-1"), Value::Int(-1)));
745        assert!(matches!(parse_value("9.5"), Value::Float(v) if v == 9.5));
746        assert!(matches!(parse_value("hello"), Value::String(v) if v == "hello"));
747    }
748
749    #[test]
750    fn enum_display_and_node_predicates_are_stable() {
751        assert_eq!(
752            NamespaceOrganization::Unspecified.to_string(),
753            "unspecified"
754        );
755        assert_eq!(NamespaceOrganization::Flat.to_string(), "flat");
756        assert_eq!(
757            NamespaceOrganization::Hierarchical.to_string(),
758            "hierarchical"
759        );
760        assert_eq!(BrowseSource::Unspecified.to_string(), "unspecified");
761        assert_eq!(BrowseSource::Da3.to_string(), "da3");
762        assert_eq!(BrowseSource::Da2.to_string(), "da2");
763        assert_eq!(BrowseSource::Flat.to_string(), "flat");
764        assert_eq!(BrowseSource::Derived.to_string(), "derived");
765        assert_eq!(BrowseNodeKind::Unspecified.to_string(), "unspecified");
766        assert_eq!(BrowseNodeKind::Branch.to_string(), "branch");
767        assert_eq!(BrowseNodeKind::Item.to_string(), "item");
768        assert_eq!(BrowseNodeKind::BranchAndItem.to_string(), "branch-and-item");
769        assert_eq!(SearchMatchMode::Exact.to_string(), "exact");
770        assert_eq!(SearchMatchMode::Prefix.to_string(), "prefix");
771        assert_eq!(SearchMatchMode::Contains.to_string(), "contains");
772        assert_eq!(SearchIndexState::Unspecified.to_string(), "unspecified");
773        assert_eq!(SearchIndexState::NotIndexed.to_string(), "not-indexed");
774        assert_eq!(SearchIndexState::Partial.to_string(), "partial");
775        assert_eq!(SearchIndexState::Ready.to_string(), "ready");
776        assert_eq!(SearchIndexState::Stale.to_string(), "stale");
777        assert_eq!(SearchIndexState::Refreshing.to_string(), "refreshing");
778        assert_eq!(SearchIndexState::Failed.to_string(), "failed");
779        assert!(BrowseNodeKind::Branch.is_branch());
780        assert!(!BrowseNodeKind::Branch.is_item());
781        assert!(BrowseNodeKind::Item.is_item());
782        assert!(!BrowseNodeKind::Item.is_branch());
783        assert!(BrowseNodeKind::BranchAndItem.is_branch());
784        assert!(BrowseNodeKind::BranchAndItem.is_item());
785        assert!(!BrowseNodeKind::Unspecified.is_branch());
786        assert!(!BrowseNodeKind::Unspecified.is_item());
787    }
788
789    #[test]
790    fn browse_request_builders_map_all_fields() {
791        let root = BrowsePageRequest::root("S", 20).with_refresh(true);
792        assert_eq!(root.server, "S");
793        assert_eq!(root.page_size, 20);
794        assert!(root.refresh);
795
796        let children = BrowsePageRequest::children("S", "session", "node", 30);
797        assert_eq!(children.session_id.as_deref(), Some("session"));
798        assert_eq!(children.parent_node_key.as_deref(), Some("node"));
799
800        let next = BrowsePageRequest::next("S", "session", Some("node".into()), "token", 40);
801        let proto: proto::BrowseRequest = next.into();
802        assert_eq!(proto.page_token.as_deref(), Some("token"));
803        assert_eq!(proto.page_size, 40);
804    }
805
806    #[test]
807    fn search_request_defaults_and_mapping_are_typed() {
808        for (mode, expected) in [
809            (SearchMatchMode::Exact, proto::SearchMatchMode::Exact),
810            (SearchMatchMode::Prefix, proto::SearchMatchMode::Prefix),
811            (SearchMatchMode::Contains, proto::SearchMatchMode::Contains),
812        ] {
813            let request = SearchRequest::new("S", "query", mode);
814            assert_eq!(request.max_results, DEFAULT_SEARCH_MAX_RESULTS);
815            let mapped: proto::SearchRequest = request.into();
816            assert_eq!(mapped.match_mode, expected as i32);
817        }
818    }
819
820    #[test]
821    fn indexed_search_request_and_controls_map_all_variants() {
822        for (mode, expected) in [
823            (SearchMatchMode::Exact, proto::SearchMatchMode::Exact),
824            (SearchMatchMode::Prefix, proto::SearchMatchMode::Prefix),
825            (SearchMatchMode::Contains, proto::SearchMatchMode::Contains),
826        ] {
827            let request = SearchIndexRequest::new("S", "query", mode);
828            assert_eq!(request.max_results, DEFAULT_INDEX_SEARCH_MAX_RESULTS);
829            let mapped: proto::SearchIndexRequest = request.into();
830            assert_eq!(mapped.match_mode, expected as i32);
831        }
832        for (action, expected) in [
833            (
834                SearchIndexControlAction::Pause,
835                proto::SearchIndexControlAction::Pause,
836            ),
837            (
838                SearchIndexControlAction::Resume,
839                proto::SearchIndexControlAction::Resume,
840            ),
841            (
842                SearchIndexControlAction::Cancel,
843                proto::SearchIndexControlAction::Cancel,
844            ),
845        ] {
846            assert_eq!(proto::SearchIndexControlAction::from(action), expected);
847        }
848    }
849
850    #[test]
851    fn invalid_and_inconsistent_proto_values_are_rejected() {
852        assert_eq!(
853            organization(proto::NamespaceOrganization::Unspecified as i32).unwrap(),
854            NamespaceOrganization::Unspecified
855        );
856        assert_eq!(
857            organization(proto::NamespaceOrganization::Flat as i32).unwrap(),
858            NamespaceOrganization::Flat
859        );
860        assert_eq!(
861            organization(proto::NamespaceOrganization::Hierarchical as i32).unwrap(),
862            NamespaceOrganization::Hierarchical
863        );
864        assert_eq!(
865            source(proto::BrowseSource::Unspecified as i32).unwrap(),
866            BrowseSource::Unspecified
867        );
868        assert_eq!(
869            source(proto::BrowseSource::Da3 as i32).unwrap(),
870            BrowseSource::Da3
871        );
872        assert_eq!(
873            source(proto::BrowseSource::Da2 as i32).unwrap(),
874            BrowseSource::Da2
875        );
876        assert_eq!(
877            source(proto::BrowseSource::Flat as i32).unwrap(),
878            BrowseSource::Flat
879        );
880        assert_eq!(
881            source(proto::BrowseSource::Derived as i32).unwrap(),
882            BrowseSource::Derived
883        );
884        assert_eq!(
885            node_kind(proto::BrowseNodeKind::Unspecified as i32).unwrap(),
886            BrowseNodeKind::Unspecified
887        );
888        assert_eq!(
889            node_kind(proto::BrowseNodeKind::Branch as i32).unwrap(),
890            BrowseNodeKind::Branch
891        );
892        assert_eq!(
893            node_kind(proto::BrowseNodeKind::Item as i32).unwrap(),
894            BrowseNodeKind::Item
895        );
896        assert_eq!(
897            node_kind(proto::BrowseNodeKind::BranchAndItem as i32).unwrap(),
898            BrowseNodeKind::BranchAndItem
899        );
900        assert!(matches!(organization(99), Err(Error::Protocol(_))));
901        assert!(matches!(source(99), Err(Error::Protocol(_))));
902        assert!(matches!(node_kind(99), Err(Error::Protocol(_))));
903        for (proto_state, state) in [
904            (
905                proto::SearchIndexState::Unspecified,
906                SearchIndexState::Unspecified,
907            ),
908            (
909                proto::SearchIndexState::NotIndexed,
910                SearchIndexState::NotIndexed,
911            ),
912            (proto::SearchIndexState::Partial, SearchIndexState::Partial),
913            (proto::SearchIndexState::Ready, SearchIndexState::Ready),
914            (proto::SearchIndexState::Stale, SearchIndexState::Stale),
915            (
916                proto::SearchIndexState::Refreshing,
917                SearchIndexState::Refreshing,
918            ),
919            (proto::SearchIndexState::Failed, SearchIndexState::Failed),
920        ] {
921            assert_eq!(search_index_state(proto_state as i32).unwrap(), state);
922        }
923        assert!(matches!(search_index_state(99), Err(Error::Protocol(_))));
924
925        let missing_item_id = proto::BrowseNode {
926            kind: proto::BrowseNodeKind::Item as i32,
927            ..Default::default()
928        };
929        assert!(matches!(
930            BrowseNode::try_from(missing_item_id),
931            Err(Error::Protocol(_))
932        ));
933        let unexpected_item_id = proto::BrowseNode {
934            kind: proto::BrowseNodeKind::Branch as i32,
935            item_id: Some("not-valid".into()),
936            ..Default::default()
937        };
938        assert!(matches!(
939            BrowseNode::try_from(unexpected_item_id),
940            Err(Error::Protocol(_))
941        ));
942
943        let complete_with_token = proto::BrowsePage {
944            complete: true,
945            next_page_token: Some("token".into()),
946            ..Default::default()
947        };
948        assert!(matches!(
949            BrowsePage::try_from(complete_with_token),
950            Err(Error::Protocol(_))
951        ));
952
953        let incomplete_without_token = proto::BrowsePage::default();
954        assert!(matches!(
955            BrowsePage::try_from(incomplete_without_token),
956            Err(Error::Protocol(_))
957        ));
958    }
959
960    #[test]
961    fn search_event_conversion_covers_every_event() {
962        let found = proto::SearchEvent {
963            event: Some(proto::search_event::Event::Match(proto::SearchMatch {
964                node: Some(proto::BrowseNode {
965                    node_key: "n".into(),
966                    display_name: "PV".into(),
967                    kind: proto::BrowseNodeKind::Item as i32,
968                    item_id: Some("FCS!TAG.PV".into()),
969                }),
970                breadcrumbs: vec![proto::BrowseBreadcrumb {
971                    node_key: "root".into(),
972                    display_name: "FCS".into(),
973                }],
974            })),
975        };
976        assert!(matches!(
977            SearchEvent::try_from(found).unwrap(),
978            SearchEvent::Match(_)
979        ));
980
981        let progress = proto::SearchEvent {
982            event: Some(proto::search_event::Event::Progress(
983                proto::SearchProgress {
984                    visited_nodes: 10,
985                    matches: 2,
986                    partial: true,
987                },
988            )),
989        };
990        assert!(matches!(
991            SearchEvent::try_from(progress).unwrap(),
992            SearchEvent::Progress(_)
993        ));
994
995        let completed = proto::SearchEvent {
996            event: Some(proto::search_event::Event::Completed(
997                proto::SearchCompleted {
998                    complete: true,
999                    cancelled: false,
1000                    truncated: false,
1001                    warning: None,
1002                },
1003            )),
1004        };
1005        assert!(matches!(
1006            SearchEvent::try_from(completed).unwrap(),
1007            SearchEvent::Completed(_)
1008        ));
1009
1010        assert!(matches!(
1011            SearchEvent::try_from(proto::SearchEvent::default()),
1012            Err(Error::Protocol(_))
1013        ));
1014        let missing_node = proto::SearchEvent {
1015            event: Some(proto::search_event::Event::Match(
1016                proto::SearchMatch::default(),
1017            )),
1018        };
1019        assert!(matches!(
1020            SearchEvent::try_from(missing_node),
1021            Err(Error::Protocol(_))
1022        ));
1023    }
1024
1025    #[test]
1026    fn indexed_search_response_preserves_identity_and_status() {
1027        let response = proto::SearchIndexResponse {
1028            matches: vec![proto::IndexedSearchMatch {
1029                item_id: "FCS0201!204FI00510.PV".into(),
1030                display_name: "PV".into(),
1031                kind: proto::BrowseNodeKind::BranchAndItem as i32,
1032                breadcrumbs: vec!["FCS0201".into(), "204FI00510".into()],
1033            }],
1034            has_more: true,
1035            status: Some(proto::SearchIndexStatus {
1036                server: "Yokogawa.CSHIS_OPC.1".into(),
1037                state: proto::SearchIndexState::Refreshing as i32,
1038                configured: true,
1039                active_generation: 7,
1040                entry_count: 100_001,
1041                unique_item_count: 100_000,
1042                started_at: Some("start".into()),
1043                completed_at: Some("complete".into()),
1044                last_error: Some("prior error".into()),
1045                database_bytes: 4096,
1046                organization: proto::NamespaceOrganization::Hierarchical as i32,
1047                source: proto::BrowseSource::Da2 as i32,
1048                progress: Some(proto::IndexedSearchProgress {
1049                    branches_visited: 10,
1050                    entries_seen: 20,
1051                    unique_items: 19,
1052                    active_time_ms: 30,
1053                    paused_time_ms: 40,
1054                    items_per_second: 12.5,
1055                    estimated_remaining_ms: Some(50),
1056                }),
1057            }),
1058        };
1059        let typed = SearchIndexResponse::try_from(response).unwrap();
1060        assert_eq!(typed.matches[0].item_id, "FCS0201!204FI00510.PV");
1061        assert_eq!(typed.matches[0].kind, BrowseNodeKind::BranchAndItem);
1062        assert_eq!(typed.status.state, SearchIndexState::Refreshing);
1063        assert_eq!(
1064            typed
1065                .status
1066                .progress
1067                .as_ref()
1068                .unwrap()
1069                .estimated_remaining_ms,
1070            Some(50)
1071        );
1072        assert!(typed.has_more);
1073
1074        let item = IndexedSearchMatch::try_from(proto::IndexedSearchMatch {
1075            kind: proto::BrowseNodeKind::Item as i32,
1076            item_id: "id".into(),
1077            ..Default::default()
1078        })
1079        .unwrap();
1080        assert_eq!(item.kind, BrowseNodeKind::Item);
1081        assert!(matches!(
1082            IndexedSearchMatch::try_from(proto::IndexedSearchMatch {
1083                kind: proto::BrowseNodeKind::Item as i32,
1084                ..Default::default()
1085            }),
1086            Err(Error::Protocol(_))
1087        ));
1088
1089        assert!(matches!(
1090            IndexedSearchMatch::try_from(proto::IndexedSearchMatch {
1091                kind: proto::BrowseNodeKind::Branch as i32,
1092                ..Default::default()
1093            }),
1094            Err(Error::Protocol(_))
1095        ));
1096        assert!(matches!(
1097            SearchIndexResponse::try_from(proto::SearchIndexResponse::default()),
1098            Err(Error::Protocol(_))
1099        ));
1100    }
1101}