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 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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub enum SearchIndexControlAction {
284 Pause,
285 Resume,
286 Cancel,
287 EnableAutoRefresh,
288 DisableAutoRefresh,
289 Delete,
290}
291
292#[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#[derive(Debug, Clone, PartialEq, Eq)]
311pub struct BrowseNode {
312 pub node_key: String,
314 pub display_name: String,
316 pub kind: BrowseNodeKind,
317 pub item_id: Option<String>,
319}
320
321#[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#[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 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 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 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 pub fn with_refresh(mut self, refresh: bool) -> Self {
394 self.refresh = refresh;
395 self
396 }
397}
398
399#[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#[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#[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#[derive(Debug, Clone, PartialEq)]
469pub struct SearchIndexStatus {
470 pub server: String,
471 pub state: SearchIndexState,
472 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#[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#[derive(Debug, Clone, PartialEq)]
508pub struct SearchIndexResponse {
509 pub matches: Vec<IndexedSearchMatch>,
510 pub has_more: bool,
511 pub status: SearchIndexStatus,
512}
513
514#[derive(Debug, Clone, PartialEq, Eq)]
516pub struct BrowseBreadcrumb {
517 pub node_key: String,
518 pub display_name: String,
519}
520
521#[derive(Debug, Clone, PartialEq, Eq)]
523pub struct SearchMatch {
524 pub node: BrowseNode,
525 pub breadcrumbs: Vec<BrowseBreadcrumb>,
526}
527
528#[derive(Debug, Clone, PartialEq, Eq)]
530pub struct SearchProgress {
531 pub visited_nodes: u32,
532 pub matches: u32,
533 pub partial: bool,
534}
535
536#[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#[derive(Debug, Clone, PartialEq, Eq)]
547pub enum SearchEvent {
548 Match(SearchMatch),
549 Progress(SearchProgress),
550 Completed(SearchCompleted),
551}
552
553#[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#[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#[derive(Debug, Clone, PartialEq)]
575pub enum Value {
576 String(String),
577 Int(i32),
578 Float(f64),
579 Bool(bool),
580}
581
582pub 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}