1use crate::{Error, Result};
4use opcda_bridge_proto::bridge as proto;
5use std::fmt;
6
7pub const DEFAULT_PAGE_SIZE: u32 = 200;
9pub const DEFAULT_SEARCH_MAX_RESULTS: u32 = 200;
11pub const DEFAULT_INDEX_SEARCH_MAX_RESULTS: u32 = 50;
13
14#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum BrowseNodeKind {
57 Unspecified,
58 Branch,
59 Item,
60 BranchAndItem,
61}
62
63impl BrowseNodeKind {
64 pub fn is_branch(self) -> bool {
66 matches!(self, Self::Branch | Self::BranchAndItem)
67 }
68
69 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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281pub enum SearchIndexControlAction {
282 Pause,
283 Resume,
284 Cancel,
285}
286
287#[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#[derive(Debug, Clone, PartialEq, Eq)]
306pub struct BrowseNode {
307 pub node_key: String,
309 pub display_name: String,
311 pub kind: BrowseNodeKind,
312 pub item_id: Option<String>,
314}
315
316#[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#[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 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 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 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 pub fn with_refresh(mut self, refresh: bool) -> Self {
389 self.refresh = refresh;
390 self
391 }
392}
393
394#[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#[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#[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#[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#[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#[derive(Debug, Clone, PartialEq)]
502pub struct SearchIndexResponse {
503 pub matches: Vec<IndexedSearchMatch>,
504 pub has_more: bool,
505 pub status: SearchIndexStatus,
506}
507
508#[derive(Debug, Clone, PartialEq, Eq)]
510pub struct BrowseBreadcrumb {
511 pub node_key: String,
512 pub display_name: String,
513}
514
515#[derive(Debug, Clone, PartialEq, Eq)]
517pub struct SearchMatch {
518 pub node: BrowseNode,
519 pub breadcrumbs: Vec<BrowseBreadcrumb>,
520}
521
522#[derive(Debug, Clone, PartialEq, Eq)]
524pub struct SearchProgress {
525 pub visited_nodes: u32,
526 pub matches: u32,
527 pub partial: bool,
528}
529
530#[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#[derive(Debug, Clone, PartialEq, Eq)]
541pub enum SearchEvent {
542 Match(SearchMatch),
543 Progress(SearchProgress),
544 Completed(SearchCompleted),
545}
546
547#[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#[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#[derive(Debug, Clone, PartialEq)]
569pub enum Value {
570 String(String),
571 Int(i32),
572 Float(f64),
573 Bool(bool),
574}
575
576pub 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}