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    Promoting,
114    Failed,
115}
116
117impl fmt::Display for SearchIndexState {
118    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119        f.write_str(match self {
120            Self::Unspecified => "unspecified",
121            Self::NotIndexed => "not-indexed",
122            Self::Partial => "partial",
123            Self::Ready => "ready",
124            Self::Stale => "stale",
125            Self::Refreshing => "refreshing",
126            Self::Promoting => "promoting",
127            Self::Failed => "failed",
128        })
129    }
130}
131
132/// Effective inventory limits currently applied by the gateway controller.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub struct IndexInventoryLimits {
135    pub item_rate_per_second: u32,
136    pub batch_size: u32,
137    pub duty_cycle_percent: u32,
138}
139
140/// Adaptive controller state for a namespace-index build.
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum IndexControllerState {
143    Unspecified,
144    Ramping,
145    Steady,
146    Throttled,
147    Paused,
148}
149
150impl fmt::Display for IndexControllerState {
151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152        f.write_str(match self {
153            Self::Unspecified => "unspecified",
154            Self::Ramping => "ramping",
155            Self::Steady => "steady",
156            Self::Throttled => "throttled",
157            Self::Paused => "paused",
158        })
159    }
160}
161
162/// Typed reason for a controller pause.
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum IndexPauseReason {
165    Unspecified,
166    Foreground,
167    OpcHealth,
168    HostCpu,
169    Memory,
170    Disk,
171    Database,
172    Operator,
173    Circuit,
174}
175
176impl fmt::Display for IndexPauseReason {
177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178        f.write_str(match self {
179            Self::Unspecified => "unspecified",
180            Self::Foreground => "foreground",
181            Self::OpcHealth => "opc-health",
182            Self::HostCpu => "host-cpu",
183            Self::Memory => "memory",
184            Self::Disk => "disk",
185            Self::Database => "database",
186            Self::Operator => "operator",
187            Self::Circuit => "circuit",
188        })
189    }
190}
191
192/// Rolling foreground operation measurements.
193#[derive(Debug, Clone, Default, PartialEq, Eq)]
194pub struct IndexForegroundDiagnostics {
195    pub active_count: u64,
196    pub operations: u64,
197    pub errors: u64,
198    pub bad_quality: u64,
199    pub latency_p50_ms: Option<u64>,
200    pub latency_p95_ms: Option<u64>,
201    pub latency_max_ms: Option<u64>,
202    pub last_error: bool,
203    pub last_bad_quality: bool,
204}
205
206/// Host and gateway-process resource measurements.
207#[derive(Debug, Clone, Default, PartialEq)]
208pub struct IndexHostDiagnostics {
209    pub cpu_percent: Option<f64>,
210    pub available_memory_percent: Option<f64>,
211    pub disk_active_percent: Option<f64>,
212    pub disk_queue: Option<f64>,
213    pub process_working_set_bytes: Option<u64>,
214    pub process_private_bytes: Option<u64>,
215    pub process_read_bytes_per_second: Option<u64>,
216    pub process_write_bytes_per_second: Option<u64>,
217    pub disk_free_bytes: Option<u64>,
218}
219
220/// SQLite file and commit measurements.
221#[derive(Debug, Clone, Default, PartialEq, Eq)]
222pub struct IndexStorageDiagnostics {
223    pub main_bytes: u64,
224    pub wal_bytes: u64,
225    pub shm_bytes: u64,
226    pub free_bytes: Option<u64>,
227    pub last_commit_latency_ms: Option<u64>,
228}
229
230/// Scheduler, retry, and circuit-breaker measurements.
231#[derive(Debug, Clone, Default, PartialEq, Eq)]
232pub struct IndexSchedulerDiagnostics {
233    pub next_refresh_at: Option<String>,
234    pub last_attempt_at: Option<String>,
235    pub last_success_at: Option<String>,
236    pub last_success_duration_ms: Option<u64>,
237    pub retry_after: Option<String>,
238    pub consecutive_failures: u32,
239    pub circuit_open: bool,
240}
241
242/// Health-probe state reported for the indexed server.
243#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
244pub enum IndexHealthState {
245    Unspecified,
246    Healthy,
247    Unhealthy,
248    #[default]
249    Unavailable,
250}
251
252impl fmt::Display for IndexHealthState {
253    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
254        f.write_str(match self {
255            Self::Unspecified => "unspecified",
256            Self::Healthy => "healthy",
257            Self::Unhealthy => "unhealthy",
258            Self::Unavailable => "unavailable",
259        })
260    }
261}
262
263/// Health-probe availability and result.
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265pub struct IndexHealthDiagnostics {
266    pub state: IndexHealthState,
267    pub sentinel_configured: bool,
268}
269
270impl Default for IndexHealthDiagnostics {
271    fn default() -> Self {
272        Self {
273            state: IndexHealthState::Unavailable,
274            sentinel_configured: false,
275        }
276    }
277}
278
279/// Operator action applied to an active namespace-index build.
280#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281pub enum SearchIndexControlAction {
282    Pause,
283    Resume,
284    Cancel,
285}
286
287/// Gateway and namespace features reported for one OPC server.
288#[derive(Debug, Clone, PartialEq, Eq)]
289pub struct Capabilities {
290    pub application_version: String,
291    pub protocol_version: String,
292    pub max_page_size: u32,
293    pub supports_browse_sessions: bool,
294    pub supports_search: bool,
295    pub organization: NamespaceOrganization,
296    pub source: BrowseSource,
297    pub supports_indexed_search: bool,
298    pub indexed_search_protocol_version: String,
299    pub max_indexed_search_results: u32,
300    pub search_index_state: SearchIndexState,
301    pub search_index_promoting: bool,
302}
303
304/// One child returned by a browse page.
305#[derive(Debug, Clone, PartialEq, Eq)]
306pub struct BrowseNode {
307    /// Opaque navigation identity. Round-trip it unchanged when expanding.
308    pub node_key: String,
309    /// One local label suitable for display.
310    pub display_name: String,
311    pub kind: BrowseNodeKind,
312    /// Exact OPC DA ItemID, present only for selectable nodes.
313    pub item_id: Option<String>,
314}
315
316/// One bounded page of immediate children and its continuation metadata.
317#[derive(Debug, Clone, PartialEq, Eq)]
318pub struct BrowsePage {
319    pub session_id: String,
320    pub nodes: Vec<BrowseNode>,
321    pub next_page_token: Option<String>,
322    pub complete: bool,
323    pub organization: NamespaceOrganization,
324    pub source: BrowseSource,
325    pub warning: Option<String>,
326}
327
328/// Parameters for one browse-page request.
329#[derive(Debug, Clone, PartialEq, Eq)]
330pub struct BrowsePageRequest {
331    pub server: String,
332    pub session_id: Option<String>,
333    pub parent_node_key: Option<String>,
334    pub page_token: Option<String>,
335    pub page_size: u32,
336    pub refresh: bool,
337}
338
339impl BrowsePageRequest {
340    /// Open a new browse session and request its root page.
341    pub fn root(server: impl Into<String>, page_size: u32) -> Self {
342        Self {
343            server: server.into(),
344            session_id: None,
345            parent_node_key: None,
346            page_token: None,
347            page_size,
348            refresh: false,
349        }
350    }
351
352    /// Request the first page beneath an already-discovered branch.
353    pub fn children(
354        server: impl Into<String>,
355        session_id: impl Into<String>,
356        parent_node_key: impl Into<String>,
357        page_size: u32,
358    ) -> Self {
359        Self {
360            server: server.into(),
361            session_id: Some(session_id.into()),
362            parent_node_key: Some(parent_node_key.into()),
363            page_token: None,
364            page_size,
365            refresh: false,
366        }
367    }
368
369    /// Request the next page for a root or child browse.
370    pub fn next(
371        server: impl Into<String>,
372        session_id: impl Into<String>,
373        parent_node_key: Option<String>,
374        page_token: impl Into<String>,
375        page_size: u32,
376    ) -> Self {
377        Self {
378            server: server.into(),
379            session_id: Some(session_id.into()),
380            parent_node_key,
381            page_token: Some(page_token.into()),
382            page_size,
383            refresh: false,
384        }
385    }
386
387    /// Ask the gateway to bypass cached namespace metadata.
388    pub fn with_refresh(mut self, refresh: bool) -> Self {
389        self.refresh = refresh;
390        self
391    }
392}
393
394/// Parameters for a bounded namespace search.
395#[derive(Debug, Clone, PartialEq, Eq)]
396pub struct SearchRequest {
397    pub server: String,
398    pub query: String,
399    pub match_mode: SearchMatchMode,
400    pub session_id: Option<String>,
401    pub scope_node_key: Option<String>,
402    pub max_results: u32,
403    pub include_branches: bool,
404    pub refresh: bool,
405}
406
407impl SearchRequest {
408    pub fn new(
409        server: impl Into<String>,
410        query: impl Into<String>,
411        match_mode: SearchMatchMode,
412    ) -> Self {
413        Self {
414            server: server.into(),
415            query: query.into(),
416            match_mode,
417            session_id: None,
418            scope_node_key: None,
419            max_results: DEFAULT_SEARCH_MAX_RESULTS,
420            include_branches: false,
421            refresh: false,
422        }
423    }
424}
425
426/// Parameters for one persistent-index query.
427#[derive(Debug, Clone, PartialEq, Eq)]
428pub struct SearchIndexRequest {
429    pub server: String,
430    pub query: String,
431    pub match_mode: SearchMatchMode,
432    pub max_results: u32,
433}
434
435impl SearchIndexRequest {
436    pub fn new(
437        server: impl Into<String>,
438        query: impl Into<String>,
439        match_mode: SearchMatchMode,
440    ) -> Self {
441        Self {
442            server: server.into(),
443            query: query.into(),
444            match_mode,
445            max_results: DEFAULT_INDEX_SEARCH_MAX_RESULTS,
446        }
447    }
448}
449
450/// Progress reported for a running persistent namespace inventory.
451#[derive(Debug, Clone, PartialEq)]
452pub struct IndexedSearchProgress {
453    pub branches_visited: u64,
454    pub entries_seen: u64,
455    pub unique_items: u64,
456    pub active_time_ms: u64,
457    pub paused_time_ms: u64,
458    pub items_per_second: f64,
459    pub estimated_remaining_ms: Option<u64>,
460}
461
462/// Persistent namespace-index state and build metadata.
463#[derive(Debug, Clone, PartialEq)]
464pub struct SearchIndexStatus {
465    pub server: String,
466    pub state: SearchIndexState,
467    pub configured: bool,
468    pub active_generation: u64,
469    pub entry_count: u64,
470    pub unique_item_count: u64,
471    pub started_at: Option<String>,
472    pub completed_at: Option<String>,
473    pub last_error: Option<String>,
474    pub database_bytes: u64,
475    pub organization: NamespaceOrganization,
476    pub source: BrowseSource,
477    pub progress: Option<IndexedSearchProgress>,
478    pub effective_limits: Option<IndexInventoryLimits>,
479    pub controller_state: IndexControllerState,
480    pub pause_reason: Option<IndexPauseReason>,
481    pub recovery_deadline: Option<String>,
482    pub pause_reason_detail: Option<String>,
483    pub foreground: IndexForegroundDiagnostics,
484    pub host: IndexHostDiagnostics,
485    pub storage: IndexStorageDiagnostics,
486    pub scheduler: IndexSchedulerDiagnostics,
487    pub health: IndexHealthDiagnostics,
488    pub promoting: bool,
489}
490
491/// One selectable result from the persistent namespace index.
492#[derive(Debug, Clone, PartialEq, Eq)]
493pub struct IndexedSearchMatch {
494    pub item_id: String,
495    pub display_name: String,
496    pub kind: BrowseNodeKind,
497    pub breadcrumbs: Vec<String>,
498}
499
500/// Ranked persistent-index matches plus snapshot readiness metadata.
501#[derive(Debug, Clone, PartialEq)]
502pub struct SearchIndexResponse {
503    pub matches: Vec<IndexedSearchMatch>,
504    pub has_more: bool,
505    pub status: SearchIndexStatus,
506}
507
508/// One navigation step associated with a search match.
509#[derive(Debug, Clone, PartialEq, Eq)]
510pub struct BrowseBreadcrumb {
511    pub node_key: String,
512    pub display_name: String,
513}
514
515/// A progressively emitted namespace-search result.
516#[derive(Debug, Clone, PartialEq, Eq)]
517pub struct SearchMatch {
518    pub node: BrowseNode,
519    pub breadcrumbs: Vec<BrowseBreadcrumb>,
520}
521
522/// Progress emitted while a namespace search is still running.
523#[derive(Debug, Clone, PartialEq, Eq)]
524pub struct SearchProgress {
525    pub visited_nodes: u32,
526    pub matches: u32,
527    pub partial: bool,
528}
529
530/// Terminal search metadata.
531#[derive(Debug, Clone, PartialEq, Eq)]
532pub struct SearchCompleted {
533    pub complete: bool,
534    pub cancelled: bool,
535    pub truncated: bool,
536    pub warning: Option<String>,
537}
538
539/// One event from the gateway's search stream.
540#[derive(Debug, Clone, PartialEq, Eq)]
541pub enum SearchEvent {
542    Match(SearchMatch),
543    Progress(SearchProgress),
544    Completed(SearchCompleted),
545}
546
547/// A single tag's semantic value returned by [`crate::Client::read`].
548///
549/// For an OPC DA `VT_BSTR`, `value` contains the exact BSTR contents. The
550/// bridge does not add or remove quote characters.
551#[derive(Debug, Clone, PartialEq, Eq)]
552pub struct TagValue {
553    pub tag_id: String,
554    pub value: String,
555    pub quality: String,
556    pub timestamp: String,
557}
558
559/// The result of a single [`crate::Client::write`] call.
560#[derive(Debug, Clone, PartialEq, Eq)]
561pub struct WriteResult {
562    pub tag_id: String,
563    pub success: bool,
564    pub error: Option<String>,
565}
566
567/// A tag value to write, parsed from a raw string via [`parse_value`].
568#[derive(Debug, Clone, PartialEq)]
569pub enum Value {
570    String(String),
571    Int(i32),
572    Float(f64),
573    Bool(bool),
574}
575
576/// Parse a raw string into bool, integer, float, or string form.
577pub fn parse_value(raw: &str) -> Value {
578    if let Ok(b) = raw.parse::<bool>() {
579        return Value::Bool(b);
580    }
581    if let Ok(i) = raw.parse::<i32>() {
582        return Value::Int(i);
583    }
584    if let Ok(f) = raw.parse::<f64>() {
585        return Value::Float(f);
586    }
587    Value::String(raw.to_string())
588}
589
590fn invalid_enum(field: &str, value: i32) -> Error {
591    Error::Protocol(format!("gateway returned unknown {field} value {value}"))
592}
593
594fn organization(value: i32) -> Result<NamespaceOrganization> {
595    match proto::NamespaceOrganization::try_from(value)
596        .map_err(|_| invalid_enum("namespace organization", value))?
597    {
598        proto::NamespaceOrganization::Unspecified => Ok(NamespaceOrganization::Unspecified),
599        proto::NamespaceOrganization::Flat => Ok(NamespaceOrganization::Flat),
600        proto::NamespaceOrganization::Hierarchical => Ok(NamespaceOrganization::Hierarchical),
601    }
602}
603
604fn source(value: i32) -> Result<BrowseSource> {
605    match proto::BrowseSource::try_from(value).map_err(|_| invalid_enum("browse source", value))? {
606        proto::BrowseSource::Unspecified => Ok(BrowseSource::Unspecified),
607        proto::BrowseSource::Da3 => Ok(BrowseSource::Da3),
608        proto::BrowseSource::Da2 => Ok(BrowseSource::Da2),
609        proto::BrowseSource::Flat => Ok(BrowseSource::Flat),
610        proto::BrowseSource::Derived => Ok(BrowseSource::Derived),
611    }
612}
613
614fn node_kind(value: i32) -> Result<BrowseNodeKind> {
615    match proto::BrowseNodeKind::try_from(value)
616        .map_err(|_| invalid_enum("browse node kind", value))?
617    {
618        proto::BrowseNodeKind::Unspecified => Ok(BrowseNodeKind::Unspecified),
619        proto::BrowseNodeKind::Branch => Ok(BrowseNodeKind::Branch),
620        proto::BrowseNodeKind::Item => Ok(BrowseNodeKind::Item),
621        proto::BrowseNodeKind::BranchAndItem => Ok(BrowseNodeKind::BranchAndItem),
622    }
623}
624
625fn search_index_state(value: i32) -> Result<SearchIndexState> {
626    match proto::SearchIndexState::try_from(value)
627        .map_err(|_| invalid_enum("search index state", value))?
628    {
629        proto::SearchIndexState::Unspecified => Ok(SearchIndexState::Unspecified),
630        proto::SearchIndexState::NotIndexed => Ok(SearchIndexState::NotIndexed),
631        proto::SearchIndexState::Partial => Ok(SearchIndexState::Partial),
632        proto::SearchIndexState::Ready => Ok(SearchIndexState::Ready),
633        proto::SearchIndexState::Stale => Ok(SearchIndexState::Stale),
634        proto::SearchIndexState::Refreshing => Ok(SearchIndexState::Refreshing),
635        proto::SearchIndexState::Promoting => Ok(SearchIndexState::Promoting),
636        proto::SearchIndexState::Failed => Ok(SearchIndexState::Failed),
637    }
638}
639
640fn index_controller_state(value: i32) -> Result<IndexControllerState> {
641    match proto::IndexControllerState::try_from(value)
642        .map_err(|_| invalid_enum("index controller state", value))?
643    {
644        proto::IndexControllerState::Unspecified => Ok(IndexControllerState::Unspecified),
645        proto::IndexControllerState::Ramping => Ok(IndexControllerState::Ramping),
646        proto::IndexControllerState::Steady => Ok(IndexControllerState::Steady),
647        proto::IndexControllerState::Throttled => Ok(IndexControllerState::Throttled),
648        proto::IndexControllerState::Paused => Ok(IndexControllerState::Paused),
649    }
650}
651
652fn index_pause_reason(value: i32) -> Result<IndexPauseReason> {
653    match proto::IndexPauseReason::try_from(value)
654        .map_err(|_| invalid_enum("index pause reason", value))?
655    {
656        proto::IndexPauseReason::Unspecified => Ok(IndexPauseReason::Unspecified),
657        proto::IndexPauseReason::Foreground => Ok(IndexPauseReason::Foreground),
658        proto::IndexPauseReason::OpcHealth => Ok(IndexPauseReason::OpcHealth),
659        proto::IndexPauseReason::HostCpu => Ok(IndexPauseReason::HostCpu),
660        proto::IndexPauseReason::Memory => Ok(IndexPauseReason::Memory),
661        proto::IndexPauseReason::Disk => Ok(IndexPauseReason::Disk),
662        proto::IndexPauseReason::Database => Ok(IndexPauseReason::Database),
663        proto::IndexPauseReason::Operator => Ok(IndexPauseReason::Operator),
664        proto::IndexPauseReason::Circuit => Ok(IndexPauseReason::Circuit),
665    }
666}
667
668fn index_health_state(value: i32) -> Result<IndexHealthState> {
669    match proto::IndexHealthState::try_from(value)
670        .map_err(|_| invalid_enum("index health state", value))?
671    {
672        proto::IndexHealthState::Unspecified => Ok(IndexHealthState::Unspecified),
673        proto::IndexHealthState::Healthy => Ok(IndexHealthState::Healthy),
674        proto::IndexHealthState::Unhealthy => Ok(IndexHealthState::Unhealthy),
675        proto::IndexHealthState::Unavailable => Ok(IndexHealthState::Unavailable),
676    }
677}
678
679impl TryFrom<proto::GetCapabilitiesResponse> for Capabilities {
680    type Error = Error;
681
682    fn try_from(value: proto::GetCapabilitiesResponse) -> Result<Self> {
683        Ok(Self {
684            application_version: value.application_version,
685            protocol_version: value.protocol_version,
686            max_page_size: value.max_page_size,
687            supports_browse_sessions: value.supports_browse_sessions,
688            supports_search: value.supports_search,
689            organization: organization(value.organization)?,
690            source: source(value.source)?,
691            supports_indexed_search: value.supports_indexed_search,
692            indexed_search_protocol_version: value.indexed_search_protocol_version,
693            max_indexed_search_results: value.max_indexed_search_results,
694            search_index_state: search_index_state(value.search_index_state)?,
695            search_index_promoting: value.search_index_promoting,
696        })
697    }
698}
699
700impl TryFrom<proto::BrowseNode> for BrowseNode {
701    type Error = Error;
702
703    fn try_from(value: proto::BrowseNode) -> Result<Self> {
704        let kind = node_kind(value.kind)?;
705        if kind.is_item() && value.item_id.is_none() {
706            return Err(Error::Protocol(
707                "gateway returned a selectable browse node without an ItemID".into(),
708            ));
709        }
710        if !kind.is_item() && value.item_id.is_some() {
711            return Err(Error::Protocol(
712                "gateway returned an ItemID for a non-selectable browse node".into(),
713            ));
714        }
715        Ok(Self {
716            node_key: value.node_key,
717            display_name: value.display_name,
718            kind,
719            item_id: value.item_id,
720        })
721    }
722}
723
724impl TryFrom<proto::BrowsePage> for BrowsePage {
725    type Error = Error;
726
727    fn try_from(value: proto::BrowsePage) -> Result<Self> {
728        if value.complete && value.next_page_token.is_some() {
729            return Err(Error::Protocol(
730                "gateway returned a complete browse page with a continuation token".into(),
731            ));
732        }
733        if !value.complete && value.next_page_token.is_none() {
734            return Err(Error::Protocol(
735                "gateway returned an incomplete browse page without a continuation token".into(),
736            ));
737        }
738        Ok(Self {
739            session_id: value.session_id,
740            nodes: value
741                .nodes
742                .into_iter()
743                .map(BrowseNode::try_from)
744                .collect::<Result<_>>()?,
745            next_page_token: value.next_page_token,
746            complete: value.complete,
747            organization: organization(value.organization)?,
748            source: source(value.source)?,
749            warning: value.warning,
750        })
751    }
752}
753
754impl From<BrowsePageRequest> for proto::BrowseRequest {
755    fn from(value: BrowsePageRequest) -> Self {
756        Self {
757            server: value.server,
758            session_id: value.session_id,
759            parent_node_key: value.parent_node_key,
760            page_token: value.page_token,
761            page_size: value.page_size,
762            refresh: value.refresh,
763        }
764    }
765}
766
767impl From<SearchRequest> for proto::SearchRequest {
768    fn from(value: SearchRequest) -> Self {
769        let match_mode = match value.match_mode {
770            SearchMatchMode::Exact => proto::SearchMatchMode::Exact,
771            SearchMatchMode::Prefix => proto::SearchMatchMode::Prefix,
772            SearchMatchMode::Contains => proto::SearchMatchMode::Contains,
773        };
774        Self {
775            server: value.server,
776            query: value.query,
777            match_mode: match_mode as i32,
778            session_id: value.session_id,
779            scope_node_key: value.scope_node_key,
780            max_results: value.max_results,
781            include_branches: value.include_branches,
782            refresh: value.refresh,
783        }
784    }
785}
786
787impl From<SearchIndexRequest> for proto::SearchIndexRequest {
788    fn from(value: SearchIndexRequest) -> Self {
789        let match_mode = match value.match_mode {
790            SearchMatchMode::Exact => proto::SearchMatchMode::Exact,
791            SearchMatchMode::Prefix => proto::SearchMatchMode::Prefix,
792            SearchMatchMode::Contains => proto::SearchMatchMode::Contains,
793        };
794        Self {
795            server: value.server,
796            query: value.query,
797            match_mode: match_mode as i32,
798            max_results: value.max_results,
799        }
800    }
801}
802
803impl From<SearchIndexControlAction> for proto::SearchIndexControlAction {
804    fn from(value: SearchIndexControlAction) -> Self {
805        match value {
806            SearchIndexControlAction::Pause => Self::Pause,
807            SearchIndexControlAction::Resume => Self::Resume,
808            SearchIndexControlAction::Cancel => Self::Cancel,
809        }
810    }
811}
812
813impl From<proto::IndexedSearchProgress> for IndexedSearchProgress {
814    fn from(value: proto::IndexedSearchProgress) -> Self {
815        Self {
816            branches_visited: value.branches_visited,
817            entries_seen: value.entries_seen,
818            unique_items: value.unique_items,
819            active_time_ms: value.active_time_ms,
820            paused_time_ms: value.paused_time_ms,
821            items_per_second: value.items_per_second,
822            estimated_remaining_ms: value.estimated_remaining_ms,
823        }
824    }
825}
826
827impl TryFrom<proto::SearchIndexStatus> for SearchIndexStatus {
828    type Error = Error;
829
830    fn try_from(value: proto::SearchIndexStatus) -> Result<Self> {
831        Ok(Self {
832            server: value.server,
833            state: search_index_state(value.state)?,
834            configured: value.configured,
835            active_generation: value.active_generation,
836            entry_count: value.entry_count,
837            unique_item_count: value.unique_item_count,
838            started_at: value.started_at,
839            completed_at: value.completed_at,
840            last_error: value.last_error,
841            database_bytes: value.database_bytes,
842            organization: organization(value.organization)?,
843            source: source(value.source)?,
844            progress: value.progress.map(Into::into),
845            effective_limits: value.effective_limits.map(|limits| IndexInventoryLimits {
846                item_rate_per_second: limits.item_rate_per_second,
847                batch_size: limits.batch_size,
848                duty_cycle_percent: limits.duty_cycle_percent,
849            }),
850            controller_state: index_controller_state(value.controller_state)?,
851            pause_reason: value.pause_reason.map(index_pause_reason).transpose()?,
852            recovery_deadline: value.recovery_deadline,
853            pause_reason_detail: value.pause_reason_detail,
854            foreground: value.foreground.map_or_else(
855                IndexForegroundDiagnostics::default,
856                |diagnostics| IndexForegroundDiagnostics {
857                    active_count: diagnostics.active_count,
858                    operations: diagnostics.operations,
859                    errors: diagnostics.errors,
860                    bad_quality: diagnostics.bad_quality,
861                    latency_p50_ms: diagnostics.latency_p50_ms,
862                    latency_p95_ms: diagnostics.latency_p95_ms,
863                    latency_max_ms: diagnostics.latency_max_ms,
864                    last_error: diagnostics.last_error,
865                    last_bad_quality: diagnostics.last_bad_quality,
866                },
867            ),
868            host: value
869                .host
870                .map_or_else(IndexHostDiagnostics::default, |diagnostics| {
871                    IndexHostDiagnostics {
872                        cpu_percent: diagnostics.cpu_percent,
873                        available_memory_percent: diagnostics.available_memory_percent,
874                        disk_active_percent: diagnostics.disk_active_percent,
875                        disk_queue: diagnostics.disk_queue,
876                        process_working_set_bytes: diagnostics.process_working_set_bytes,
877                        process_private_bytes: diagnostics.process_private_bytes,
878                        process_read_bytes_per_second: diagnostics.process_read_bytes_per_second,
879                        process_write_bytes_per_second: diagnostics.process_write_bytes_per_second,
880                        disk_free_bytes: diagnostics.disk_free_bytes,
881                    }
882                }),
883            storage: value
884                .storage
885                .map_or_else(IndexStorageDiagnostics::default, |diagnostics| {
886                    IndexStorageDiagnostics {
887                        main_bytes: diagnostics.main_bytes,
888                        wal_bytes: diagnostics.wal_bytes,
889                        shm_bytes: diagnostics.shm_bytes,
890                        free_bytes: diagnostics.free_bytes,
891                        last_commit_latency_ms: diagnostics.last_commit_latency_ms,
892                    }
893                }),
894            scheduler: value.scheduler.map_or_else(
895                IndexSchedulerDiagnostics::default,
896                |diagnostics| IndexSchedulerDiagnostics {
897                    next_refresh_at: diagnostics.next_refresh_at,
898                    last_attempt_at: diagnostics.last_attempt_at,
899                    last_success_at: diagnostics.last_success_at,
900                    last_success_duration_ms: diagnostics.last_success_duration_ms,
901                    retry_after: diagnostics.retry_after,
902                    consecutive_failures: diagnostics.consecutive_failures,
903                    circuit_open: diagnostics.circuit_open,
904                },
905            ),
906            health: value
907                .health
908                .map(|diagnostics| -> Result<IndexHealthDiagnostics> {
909                    Ok(IndexHealthDiagnostics {
910                        state: index_health_state(diagnostics.state)?,
911                        sentinel_configured: diagnostics.sentinel_configured,
912                    })
913                })
914                .transpose()?
915                .unwrap_or_default(),
916            promoting: value.promoting,
917        })
918    }
919}
920
921impl TryFrom<proto::IndexedSearchMatch> for IndexedSearchMatch {
922    type Error = Error;
923
924    fn try_from(value: proto::IndexedSearchMatch) -> Result<Self> {
925        let kind = node_kind(value.kind)?;
926        if !kind.is_item() {
927            return Err(Error::Protocol(
928                "gateway returned a non-selectable indexed search match".into(),
929            ));
930        }
931        if value.item_id.is_empty() {
932            return Err(Error::Protocol(
933                "gateway returned an indexed search match without an ItemID".into(),
934            ));
935        }
936        Ok(Self {
937            item_id: value.item_id,
938            display_name: value.display_name,
939            kind,
940            breadcrumbs: value.breadcrumbs,
941        })
942    }
943}
944
945impl TryFrom<proto::SearchIndexResponse> for SearchIndexResponse {
946    type Error = Error;
947
948    fn try_from(value: proto::SearchIndexResponse) -> Result<Self> {
949        Ok(Self {
950            matches: value
951                .matches
952                .into_iter()
953                .map(IndexedSearchMatch::try_from)
954                .collect::<Result<_>>()?,
955            has_more: value.has_more,
956            status: value
957                .status
958                .ok_or_else(|| {
959                    Error::Protocol("gateway returned indexed search results without status".into())
960                })?
961                .try_into()?,
962        })
963    }
964}
965
966impl TryFrom<proto::SearchEvent> for SearchEvent {
967    type Error = Error;
968
969    fn try_from(value: proto::SearchEvent) -> Result<Self> {
970        match value.event {
971            Some(proto::search_event::Event::Match(found)) => {
972                let node = found.node.ok_or_else(|| {
973                    Error::Protocol("gateway returned a search match without a node".into())
974                })?;
975                Ok(Self::Match(SearchMatch {
976                    node: node.try_into()?,
977                    breadcrumbs: found
978                        .breadcrumbs
979                        .into_iter()
980                        .map(|part| BrowseBreadcrumb {
981                            node_key: part.node_key,
982                            display_name: part.display_name,
983                        })
984                        .collect(),
985                }))
986            }
987            Some(proto::search_event::Event::Progress(progress)) => {
988                Ok(Self::Progress(SearchProgress {
989                    visited_nodes: progress.visited_nodes,
990                    matches: progress.matches,
991                    partial: progress.partial,
992                }))
993            }
994            Some(proto::search_event::Event::Completed(completed)) => {
995                Ok(Self::Completed(SearchCompleted {
996                    complete: completed.complete,
997                    cancelled: completed.cancelled,
998                    truncated: completed.truncated,
999                    warning: completed.warning,
1000                }))
1001            }
1002            None => Err(Error::Protocol(
1003                "gateway returned an empty search event".into(),
1004            )),
1005        }
1006    }
1007}
1008
1009#[cfg(test)]
1010mod tests {
1011    use super::*;
1012
1013    #[test]
1014    fn value_parsing_covers_all_variants() {
1015        assert!(matches!(parse_value("true"), Value::Bool(true)));
1016        assert!(matches!(parse_value("false"), Value::Bool(false)));
1017        assert!(matches!(parse_value("42"), Value::Int(42)));
1018        assert!(matches!(parse_value("-1"), Value::Int(-1)));
1019        assert!(matches!(parse_value("9.5"), Value::Float(v) if v == 9.5));
1020        assert!(matches!(parse_value("hello"), Value::String(v) if v == "hello"));
1021    }
1022
1023    #[test]
1024    fn enum_display_and_node_predicates_are_stable() {
1025        assert_eq!(
1026            NamespaceOrganization::Unspecified.to_string(),
1027            "unspecified"
1028        );
1029        assert_eq!(NamespaceOrganization::Flat.to_string(), "flat");
1030        assert_eq!(
1031            NamespaceOrganization::Hierarchical.to_string(),
1032            "hierarchical"
1033        );
1034        assert_eq!(BrowseSource::Unspecified.to_string(), "unspecified");
1035        assert_eq!(BrowseSource::Da3.to_string(), "da3");
1036        assert_eq!(BrowseSource::Da2.to_string(), "da2");
1037        assert_eq!(BrowseSource::Flat.to_string(), "flat");
1038        assert_eq!(BrowseSource::Derived.to_string(), "derived");
1039        assert_eq!(BrowseNodeKind::Unspecified.to_string(), "unspecified");
1040        assert_eq!(BrowseNodeKind::Branch.to_string(), "branch");
1041        assert_eq!(BrowseNodeKind::Item.to_string(), "item");
1042        assert_eq!(BrowseNodeKind::BranchAndItem.to_string(), "branch-and-item");
1043        assert_eq!(SearchMatchMode::Exact.to_string(), "exact");
1044        assert_eq!(SearchMatchMode::Prefix.to_string(), "prefix");
1045        assert_eq!(SearchMatchMode::Contains.to_string(), "contains");
1046        assert_eq!(SearchIndexState::Unspecified.to_string(), "unspecified");
1047        assert_eq!(SearchIndexState::NotIndexed.to_string(), "not-indexed");
1048        assert_eq!(SearchIndexState::Partial.to_string(), "partial");
1049        assert_eq!(SearchIndexState::Ready.to_string(), "ready");
1050        assert_eq!(SearchIndexState::Stale.to_string(), "stale");
1051        assert_eq!(SearchIndexState::Refreshing.to_string(), "refreshing");
1052        assert_eq!(SearchIndexState::Promoting.to_string(), "promoting");
1053        assert_eq!(SearchIndexState::Failed.to_string(), "failed");
1054        assert_eq!(IndexControllerState::Unspecified.to_string(), "unspecified");
1055        assert_eq!(IndexControllerState::Ramping.to_string(), "ramping");
1056        assert_eq!(IndexControllerState::Steady.to_string(), "steady");
1057        assert_eq!(IndexControllerState::Throttled.to_string(), "throttled");
1058        assert_eq!(IndexControllerState::Paused.to_string(), "paused");
1059        assert_eq!(IndexPauseReason::Unspecified.to_string(), "unspecified");
1060        assert_eq!(IndexPauseReason::Foreground.to_string(), "foreground");
1061        assert_eq!(IndexPauseReason::OpcHealth.to_string(), "opc-health");
1062        assert_eq!(IndexPauseReason::HostCpu.to_string(), "host-cpu");
1063        assert_eq!(IndexPauseReason::Memory.to_string(), "memory");
1064        assert_eq!(IndexPauseReason::Disk.to_string(), "disk");
1065        assert_eq!(IndexPauseReason::Database.to_string(), "database");
1066        assert_eq!(IndexPauseReason::Operator.to_string(), "operator");
1067        assert_eq!(IndexPauseReason::Circuit.to_string(), "circuit");
1068        assert_eq!(IndexHealthState::Unspecified.to_string(), "unspecified");
1069        assert_eq!(IndexHealthState::Healthy.to_string(), "healthy");
1070        assert_eq!(IndexHealthState::Unhealthy.to_string(), "unhealthy");
1071        assert_eq!(IndexHealthState::Unavailable.to_string(), "unavailable");
1072        assert!(BrowseNodeKind::Branch.is_branch());
1073        assert!(!BrowseNodeKind::Branch.is_item());
1074        assert!(BrowseNodeKind::Item.is_item());
1075        assert!(!BrowseNodeKind::Item.is_branch());
1076        assert!(BrowseNodeKind::BranchAndItem.is_branch());
1077        assert!(BrowseNodeKind::BranchAndItem.is_item());
1078        assert!(!BrowseNodeKind::Unspecified.is_branch());
1079        assert!(!BrowseNodeKind::Unspecified.is_item());
1080    }
1081
1082    #[test]
1083    fn browse_request_builders_map_all_fields() {
1084        let root = BrowsePageRequest::root("S", 20).with_refresh(true);
1085        assert_eq!(root.server, "S");
1086        assert_eq!(root.page_size, 20);
1087        assert!(root.refresh);
1088
1089        let children = BrowsePageRequest::children("S", "session", "node", 30);
1090        assert_eq!(children.session_id.as_deref(), Some("session"));
1091        assert_eq!(children.parent_node_key.as_deref(), Some("node"));
1092
1093        let next = BrowsePageRequest::next("S", "session", Some("node".into()), "token", 40);
1094        let proto: proto::BrowseRequest = next.into();
1095        assert_eq!(proto.page_token.as_deref(), Some("token"));
1096        assert_eq!(proto.page_size, 40);
1097    }
1098
1099    #[test]
1100    fn search_request_defaults_and_mapping_are_typed() {
1101        for (mode, expected) in [
1102            (SearchMatchMode::Exact, proto::SearchMatchMode::Exact),
1103            (SearchMatchMode::Prefix, proto::SearchMatchMode::Prefix),
1104            (SearchMatchMode::Contains, proto::SearchMatchMode::Contains),
1105        ] {
1106            let request = SearchRequest::new("S", "query", mode);
1107            assert_eq!(request.max_results, DEFAULT_SEARCH_MAX_RESULTS);
1108            let mapped: proto::SearchRequest = request.into();
1109            assert_eq!(mapped.match_mode, expected as i32);
1110        }
1111    }
1112
1113    #[test]
1114    fn indexed_search_request_and_controls_map_all_variants() {
1115        for (mode, expected) in [
1116            (SearchMatchMode::Exact, proto::SearchMatchMode::Exact),
1117            (SearchMatchMode::Prefix, proto::SearchMatchMode::Prefix),
1118            (SearchMatchMode::Contains, proto::SearchMatchMode::Contains),
1119        ] {
1120            let request = SearchIndexRequest::new("S", "query", mode);
1121            assert_eq!(request.max_results, DEFAULT_INDEX_SEARCH_MAX_RESULTS);
1122            let mapped: proto::SearchIndexRequest = request.into();
1123            assert_eq!(mapped.match_mode, expected as i32);
1124        }
1125        for (action, expected) in [
1126            (
1127                SearchIndexControlAction::Pause,
1128                proto::SearchIndexControlAction::Pause,
1129            ),
1130            (
1131                SearchIndexControlAction::Resume,
1132                proto::SearchIndexControlAction::Resume,
1133            ),
1134            (
1135                SearchIndexControlAction::Cancel,
1136                proto::SearchIndexControlAction::Cancel,
1137            ),
1138        ] {
1139            assert_eq!(proto::SearchIndexControlAction::from(action), expected);
1140        }
1141    }
1142
1143    #[test]
1144    fn invalid_and_inconsistent_proto_values_are_rejected() {
1145        assert_eq!(
1146            organization(proto::NamespaceOrganization::Unspecified as i32).unwrap(),
1147            NamespaceOrganization::Unspecified
1148        );
1149        assert_eq!(
1150            organization(proto::NamespaceOrganization::Flat as i32).unwrap(),
1151            NamespaceOrganization::Flat
1152        );
1153        assert_eq!(
1154            organization(proto::NamespaceOrganization::Hierarchical as i32).unwrap(),
1155            NamespaceOrganization::Hierarchical
1156        );
1157        assert_eq!(
1158            source(proto::BrowseSource::Unspecified as i32).unwrap(),
1159            BrowseSource::Unspecified
1160        );
1161        assert_eq!(
1162            source(proto::BrowseSource::Da3 as i32).unwrap(),
1163            BrowseSource::Da3
1164        );
1165        assert_eq!(
1166            source(proto::BrowseSource::Da2 as i32).unwrap(),
1167            BrowseSource::Da2
1168        );
1169        assert_eq!(
1170            source(proto::BrowseSource::Flat as i32).unwrap(),
1171            BrowseSource::Flat
1172        );
1173        assert_eq!(
1174            source(proto::BrowseSource::Derived as i32).unwrap(),
1175            BrowseSource::Derived
1176        );
1177        assert_eq!(
1178            node_kind(proto::BrowseNodeKind::Unspecified as i32).unwrap(),
1179            BrowseNodeKind::Unspecified
1180        );
1181        assert_eq!(
1182            node_kind(proto::BrowseNodeKind::Branch as i32).unwrap(),
1183            BrowseNodeKind::Branch
1184        );
1185        assert_eq!(
1186            node_kind(proto::BrowseNodeKind::Item as i32).unwrap(),
1187            BrowseNodeKind::Item
1188        );
1189        assert_eq!(
1190            node_kind(proto::BrowseNodeKind::BranchAndItem as i32).unwrap(),
1191            BrowseNodeKind::BranchAndItem
1192        );
1193        assert!(matches!(organization(99), Err(Error::Protocol(_))));
1194        assert!(matches!(source(99), Err(Error::Protocol(_))));
1195        assert!(matches!(node_kind(99), Err(Error::Protocol(_))));
1196        for (proto_state, state) in [
1197            (
1198                proto::SearchIndexState::Unspecified,
1199                SearchIndexState::Unspecified,
1200            ),
1201            (
1202                proto::SearchIndexState::NotIndexed,
1203                SearchIndexState::NotIndexed,
1204            ),
1205            (proto::SearchIndexState::Partial, SearchIndexState::Partial),
1206            (proto::SearchIndexState::Ready, SearchIndexState::Ready),
1207            (proto::SearchIndexState::Stale, SearchIndexState::Stale),
1208            (
1209                proto::SearchIndexState::Refreshing,
1210                SearchIndexState::Refreshing,
1211            ),
1212            (
1213                proto::SearchIndexState::Promoting,
1214                SearchIndexState::Promoting,
1215            ),
1216            (proto::SearchIndexState::Failed, SearchIndexState::Failed),
1217        ] {
1218            assert_eq!(search_index_state(proto_state as i32).unwrap(), state);
1219        }
1220        assert!(matches!(search_index_state(99), Err(Error::Protocol(_))));
1221        for (proto_state, state) in [
1222            (
1223                proto::IndexControllerState::Unspecified,
1224                IndexControllerState::Unspecified,
1225            ),
1226            (
1227                proto::IndexControllerState::Ramping,
1228                IndexControllerState::Ramping,
1229            ),
1230            (
1231                proto::IndexControllerState::Steady,
1232                IndexControllerState::Steady,
1233            ),
1234            (
1235                proto::IndexControllerState::Throttled,
1236                IndexControllerState::Throttled,
1237            ),
1238            (
1239                proto::IndexControllerState::Paused,
1240                IndexControllerState::Paused,
1241            ),
1242        ] {
1243            assert_eq!(index_controller_state(proto_state as i32).unwrap(), state);
1244        }
1245        assert!(matches!(
1246            index_controller_state(99),
1247            Err(Error::Protocol(_))
1248        ));
1249        for (proto_reason, reason) in [
1250            (
1251                proto::IndexPauseReason::Unspecified,
1252                IndexPauseReason::Unspecified,
1253            ),
1254            (
1255                proto::IndexPauseReason::Foreground,
1256                IndexPauseReason::Foreground,
1257            ),
1258            (
1259                proto::IndexPauseReason::OpcHealth,
1260                IndexPauseReason::OpcHealth,
1261            ),
1262            (proto::IndexPauseReason::HostCpu, IndexPauseReason::HostCpu),
1263            (proto::IndexPauseReason::Memory, IndexPauseReason::Memory),
1264            (proto::IndexPauseReason::Disk, IndexPauseReason::Disk),
1265            (
1266                proto::IndexPauseReason::Database,
1267                IndexPauseReason::Database,
1268            ),
1269            (
1270                proto::IndexPauseReason::Operator,
1271                IndexPauseReason::Operator,
1272            ),
1273            (proto::IndexPauseReason::Circuit, IndexPauseReason::Circuit),
1274        ] {
1275            assert_eq!(index_pause_reason(proto_reason as i32).unwrap(), reason);
1276        }
1277        assert!(matches!(index_pause_reason(99), Err(Error::Protocol(_))));
1278        for (proto_state, state) in [
1279            (
1280                proto::IndexHealthState::Unspecified,
1281                IndexHealthState::Unspecified,
1282            ),
1283            (proto::IndexHealthState::Healthy, IndexHealthState::Healthy),
1284            (
1285                proto::IndexHealthState::Unhealthy,
1286                IndexHealthState::Unhealthy,
1287            ),
1288            (
1289                proto::IndexHealthState::Unavailable,
1290                IndexHealthState::Unavailable,
1291            ),
1292        ] {
1293            assert_eq!(index_health_state(proto_state as i32).unwrap(), state);
1294        }
1295        assert!(matches!(index_health_state(99), Err(Error::Protocol(_))));
1296
1297        let missing_item_id = proto::BrowseNode {
1298            kind: proto::BrowseNodeKind::Item as i32,
1299            ..Default::default()
1300        };
1301        assert!(matches!(
1302            BrowseNode::try_from(missing_item_id),
1303            Err(Error::Protocol(_))
1304        ));
1305        let unexpected_item_id = proto::BrowseNode {
1306            kind: proto::BrowseNodeKind::Branch as i32,
1307            item_id: Some("not-valid".into()),
1308            ..Default::default()
1309        };
1310        assert!(matches!(
1311            BrowseNode::try_from(unexpected_item_id),
1312            Err(Error::Protocol(_))
1313        ));
1314
1315        let complete_with_token = proto::BrowsePage {
1316            complete: true,
1317            next_page_token: Some("token".into()),
1318            ..Default::default()
1319        };
1320        assert!(matches!(
1321            BrowsePage::try_from(complete_with_token),
1322            Err(Error::Protocol(_))
1323        ));
1324
1325        let incomplete_without_token = proto::BrowsePage::default();
1326        assert!(matches!(
1327            BrowsePage::try_from(incomplete_without_token),
1328            Err(Error::Protocol(_))
1329        ));
1330    }
1331
1332    #[test]
1333    fn search_event_conversion_covers_every_event() {
1334        let found = proto::SearchEvent {
1335            event: Some(proto::search_event::Event::Match(proto::SearchMatch {
1336                node: Some(proto::BrowseNode {
1337                    node_key: "n".into(),
1338                    display_name: "PV".into(),
1339                    kind: proto::BrowseNodeKind::Item as i32,
1340                    item_id: Some("FCS!TAG.PV".into()),
1341                }),
1342                breadcrumbs: vec![proto::BrowseBreadcrumb {
1343                    node_key: "root".into(),
1344                    display_name: "FCS".into(),
1345                }],
1346            })),
1347        };
1348        assert!(matches!(
1349            SearchEvent::try_from(found).unwrap(),
1350            SearchEvent::Match(_)
1351        ));
1352
1353        let progress = proto::SearchEvent {
1354            event: Some(proto::search_event::Event::Progress(
1355                proto::SearchProgress {
1356                    visited_nodes: 10,
1357                    matches: 2,
1358                    partial: true,
1359                },
1360            )),
1361        };
1362        assert!(matches!(
1363            SearchEvent::try_from(progress).unwrap(),
1364            SearchEvent::Progress(_)
1365        ));
1366
1367        let completed = proto::SearchEvent {
1368            event: Some(proto::search_event::Event::Completed(
1369                proto::SearchCompleted {
1370                    complete: true,
1371                    cancelled: false,
1372                    truncated: false,
1373                    warning: None,
1374                },
1375            )),
1376        };
1377        assert!(matches!(
1378            SearchEvent::try_from(completed).unwrap(),
1379            SearchEvent::Completed(_)
1380        ));
1381
1382        assert!(matches!(
1383            SearchEvent::try_from(proto::SearchEvent::default()),
1384            Err(Error::Protocol(_))
1385        ));
1386        let missing_node = proto::SearchEvent {
1387            event: Some(proto::search_event::Event::Match(
1388                proto::SearchMatch::default(),
1389            )),
1390        };
1391        assert!(matches!(
1392            SearchEvent::try_from(missing_node),
1393            Err(Error::Protocol(_))
1394        ));
1395    }
1396
1397    #[test]
1398    fn indexed_search_response_preserves_identity_and_status() {
1399        let response = proto::SearchIndexResponse {
1400            matches: vec![proto::IndexedSearchMatch {
1401                item_id: "FCS0201!204FI00510.PV".into(),
1402                display_name: "PV".into(),
1403                kind: proto::BrowseNodeKind::BranchAndItem as i32,
1404                breadcrumbs: vec!["FCS0201".into(), "204FI00510".into()],
1405            }],
1406            has_more: true,
1407            status: Some(proto::SearchIndexStatus {
1408                server: "Yokogawa.CSHIS_OPC.1".into(),
1409                state: proto::SearchIndexState::Refreshing as i32,
1410                configured: true,
1411                active_generation: 7,
1412                entry_count: 100_001,
1413                unique_item_count: 100_000,
1414                started_at: Some("start".into()),
1415                completed_at: Some("complete".into()),
1416                last_error: Some("prior error".into()),
1417                database_bytes: 4096,
1418                organization: proto::NamespaceOrganization::Hierarchical as i32,
1419                source: proto::BrowseSource::Da2 as i32,
1420                progress: Some(proto::IndexedSearchProgress {
1421                    branches_visited: 10,
1422                    entries_seen: 20,
1423                    unique_items: 19,
1424                    active_time_ms: 30,
1425                    paused_time_ms: 40,
1426                    items_per_second: 12.5,
1427                    estimated_remaining_ms: Some(50),
1428                }),
1429                effective_limits: Some(proto::IndexInventoryLimits {
1430                    item_rate_per_second: 100,
1431                    batch_size: 25,
1432                    duty_cycle_percent: 5,
1433                }),
1434                controller_state: proto::IndexControllerState::Throttled as i32,
1435                pause_reason: Some(proto::IndexPauseReason::Database as i32),
1436                recovery_deadline: Some("recover".into()),
1437                pause_reason_detail: Some("commit latency".into()),
1438                foreground: Some(proto::IndexForegroundDiagnostics {
1439                    active_count: 1,
1440                    operations: 2,
1441                    errors: 3,
1442                    bad_quality: 4,
1443                    latency_p50_ms: Some(5),
1444                    latency_p95_ms: Some(6),
1445                    latency_max_ms: Some(7),
1446                    last_error: true,
1447                    last_bad_quality: true,
1448                }),
1449                host: Some(proto::IndexHostDiagnostics {
1450                    cpu_percent: Some(8.0),
1451                    available_memory_percent: Some(9.0),
1452                    disk_active_percent: Some(10.0),
1453                    disk_queue: Some(11.0),
1454                    process_working_set_bytes: Some(12),
1455                    process_private_bytes: Some(13),
1456                    process_read_bytes_per_second: Some(14),
1457                    process_write_bytes_per_second: Some(15),
1458                    disk_free_bytes: Some(16),
1459                }),
1460                storage: Some(proto::IndexStorageDiagnostics {
1461                    main_bytes: 17,
1462                    wal_bytes: 18,
1463                    shm_bytes: 19,
1464                    free_bytes: Some(20),
1465                    last_commit_latency_ms: Some(21),
1466                }),
1467                scheduler: Some(proto::IndexSchedulerDiagnostics {
1468                    next_refresh_at: Some("next".into()),
1469                    last_attempt_at: Some("attempt".into()),
1470                    last_success_at: Some("success".into()),
1471                    last_success_duration_ms: Some(22),
1472                    retry_after: Some("retry".into()),
1473                    consecutive_failures: 23,
1474                    circuit_open: true,
1475                }),
1476                health: Some(proto::IndexHealthDiagnostics {
1477                    state: proto::IndexHealthState::Healthy as i32,
1478                    sentinel_configured: true,
1479                }),
1480                promoting: true,
1481            }),
1482        };
1483        let typed = SearchIndexResponse::try_from(response).unwrap();
1484        assert_eq!(typed.matches[0].item_id, "FCS0201!204FI00510.PV");
1485        assert_eq!(typed.matches[0].kind, BrowseNodeKind::BranchAndItem);
1486        assert_eq!(typed.status.state, SearchIndexState::Refreshing);
1487        assert_eq!(
1488            typed
1489                .status
1490                .progress
1491                .as_ref()
1492                .unwrap()
1493                .estimated_remaining_ms,
1494            Some(50)
1495        );
1496        assert_eq!(
1497            typed.status.effective_limits,
1498            Some(IndexInventoryLimits {
1499                item_rate_per_second: 100,
1500                batch_size: 25,
1501                duty_cycle_percent: 5,
1502            })
1503        );
1504        assert_eq!(
1505            typed.status.controller_state,
1506            IndexControllerState::Throttled
1507        );
1508        assert_eq!(typed.status.pause_reason, Some(IndexPauseReason::Database));
1509        assert_eq!(typed.status.recovery_deadline.as_deref(), Some("recover"));
1510        assert_eq!(
1511            typed.status.pause_reason_detail.as_deref(),
1512            Some("commit latency")
1513        );
1514        assert_eq!(
1515            typed.status.foreground,
1516            IndexForegroundDiagnostics {
1517                active_count: 1,
1518                operations: 2,
1519                errors: 3,
1520                bad_quality: 4,
1521                latency_p50_ms: Some(5),
1522                latency_p95_ms: Some(6),
1523                latency_max_ms: Some(7),
1524                last_error: true,
1525                last_bad_quality: true,
1526            }
1527        );
1528        assert_eq!(
1529            typed.status.host,
1530            IndexHostDiagnostics {
1531                cpu_percent: Some(8.0),
1532                available_memory_percent: Some(9.0),
1533                disk_active_percent: Some(10.0),
1534                disk_queue: Some(11.0),
1535                process_working_set_bytes: Some(12),
1536                process_private_bytes: Some(13),
1537                process_read_bytes_per_second: Some(14),
1538                process_write_bytes_per_second: Some(15),
1539                disk_free_bytes: Some(16),
1540            }
1541        );
1542        assert_eq!(
1543            typed.status.storage,
1544            IndexStorageDiagnostics {
1545                main_bytes: 17,
1546                wal_bytes: 18,
1547                shm_bytes: 19,
1548                free_bytes: Some(20),
1549                last_commit_latency_ms: Some(21),
1550            }
1551        );
1552        assert_eq!(
1553            typed.status.scheduler,
1554            IndexSchedulerDiagnostics {
1555                next_refresh_at: Some("next".into()),
1556                last_attempt_at: Some("attempt".into()),
1557                last_success_at: Some("success".into()),
1558                last_success_duration_ms: Some(22),
1559                retry_after: Some("retry".into()),
1560                consecutive_failures: 23,
1561                circuit_open: true,
1562            }
1563        );
1564        assert_eq!(
1565            typed.status.health,
1566            IndexHealthDiagnostics {
1567                state: IndexHealthState::Healthy,
1568                sentinel_configured: true,
1569            }
1570        );
1571        assert!(typed.status.promoting);
1572        assert!(typed.has_more);
1573
1574        let defaults = SearchIndexStatus::try_from(proto::SearchIndexStatus::default()).unwrap();
1575        assert_eq!(defaults.foreground, IndexForegroundDiagnostics::default());
1576        assert_eq!(defaults.host, IndexHostDiagnostics::default());
1577        assert_eq!(defaults.storage, IndexStorageDiagnostics::default());
1578        assert_eq!(defaults.scheduler, IndexSchedulerDiagnostics::default());
1579        assert_eq!(defaults.health, IndexHealthDiagnostics::default());
1580
1581        let item = IndexedSearchMatch::try_from(proto::IndexedSearchMatch {
1582            kind: proto::BrowseNodeKind::Item as i32,
1583            item_id: "id".into(),
1584            ..Default::default()
1585        })
1586        .unwrap();
1587        assert_eq!(item.kind, BrowseNodeKind::Item);
1588        assert!(matches!(
1589            IndexedSearchMatch::try_from(proto::IndexedSearchMatch {
1590                kind: proto::BrowseNodeKind::Item as i32,
1591                ..Default::default()
1592            }),
1593            Err(Error::Protocol(_))
1594        ));
1595
1596        assert!(matches!(
1597            IndexedSearchMatch::try_from(proto::IndexedSearchMatch {
1598                kind: proto::BrowseNodeKind::Branch as i32,
1599                ..Default::default()
1600            }),
1601            Err(Error::Protocol(_))
1602        ));
1603        assert!(matches!(
1604            SearchIndexResponse::try_from(proto::SearchIndexResponse::default()),
1605            Err(Error::Protocol(_))
1606        ));
1607    }
1608}