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