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 Failed,
114}
115
116impl fmt::Display for SearchIndexState {
117 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118 f.write_str(match self {
119 Self::Unspecified => "unspecified",
120 Self::NotIndexed => "not-indexed",
121 Self::Partial => "partial",
122 Self::Ready => "ready",
123 Self::Stale => "stale",
124 Self::Refreshing => "refreshing",
125 Self::Failed => "failed",
126 })
127 }
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum SearchIndexControlAction {
133 Pause,
134 Resume,
135 Cancel,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct Capabilities {
141 pub application_version: String,
142 pub protocol_version: String,
143 pub max_page_size: u32,
144 pub supports_browse_sessions: bool,
145 pub supports_search: bool,
146 pub organization: NamespaceOrganization,
147 pub source: BrowseSource,
148 pub supports_indexed_search: bool,
149 pub indexed_search_protocol_version: String,
150 pub max_indexed_search_results: u32,
151 pub search_index_state: SearchIndexState,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct BrowseNode {
157 pub node_key: String,
159 pub display_name: String,
161 pub kind: BrowseNodeKind,
162 pub item_id: Option<String>,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct BrowsePage {
169 pub session_id: String,
170 pub nodes: Vec<BrowseNode>,
171 pub next_page_token: Option<String>,
172 pub complete: bool,
173 pub organization: NamespaceOrganization,
174 pub source: BrowseSource,
175 pub warning: Option<String>,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct BrowsePageRequest {
181 pub server: String,
182 pub session_id: Option<String>,
183 pub parent_node_key: Option<String>,
184 pub page_token: Option<String>,
185 pub page_size: u32,
186 pub refresh: bool,
187}
188
189impl BrowsePageRequest {
190 pub fn root(server: impl Into<String>, page_size: u32) -> Self {
192 Self {
193 server: server.into(),
194 session_id: None,
195 parent_node_key: None,
196 page_token: None,
197 page_size,
198 refresh: false,
199 }
200 }
201
202 pub fn children(
204 server: impl Into<String>,
205 session_id: impl Into<String>,
206 parent_node_key: impl Into<String>,
207 page_size: u32,
208 ) -> Self {
209 Self {
210 server: server.into(),
211 session_id: Some(session_id.into()),
212 parent_node_key: Some(parent_node_key.into()),
213 page_token: None,
214 page_size,
215 refresh: false,
216 }
217 }
218
219 pub fn next(
221 server: impl Into<String>,
222 session_id: impl Into<String>,
223 parent_node_key: Option<String>,
224 page_token: impl Into<String>,
225 page_size: u32,
226 ) -> Self {
227 Self {
228 server: server.into(),
229 session_id: Some(session_id.into()),
230 parent_node_key,
231 page_token: Some(page_token.into()),
232 page_size,
233 refresh: false,
234 }
235 }
236
237 pub fn with_refresh(mut self, refresh: bool) -> Self {
239 self.refresh = refresh;
240 self
241 }
242}
243
244#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct SearchRequest {
247 pub server: String,
248 pub query: String,
249 pub match_mode: SearchMatchMode,
250 pub session_id: Option<String>,
251 pub scope_node_key: Option<String>,
252 pub max_results: u32,
253 pub include_branches: bool,
254 pub refresh: bool,
255}
256
257impl SearchRequest {
258 pub fn new(
259 server: impl Into<String>,
260 query: impl Into<String>,
261 match_mode: SearchMatchMode,
262 ) -> Self {
263 Self {
264 server: server.into(),
265 query: query.into(),
266 match_mode,
267 session_id: None,
268 scope_node_key: None,
269 max_results: DEFAULT_SEARCH_MAX_RESULTS,
270 include_branches: false,
271 refresh: false,
272 }
273 }
274}
275
276#[derive(Debug, Clone, PartialEq, Eq)]
278pub struct SearchIndexRequest {
279 pub server: String,
280 pub query: String,
281 pub match_mode: SearchMatchMode,
282 pub max_results: u32,
283}
284
285impl SearchIndexRequest {
286 pub fn new(
287 server: impl Into<String>,
288 query: impl Into<String>,
289 match_mode: SearchMatchMode,
290 ) -> Self {
291 Self {
292 server: server.into(),
293 query: query.into(),
294 match_mode,
295 max_results: DEFAULT_INDEX_SEARCH_MAX_RESULTS,
296 }
297 }
298}
299
300#[derive(Debug, Clone, PartialEq)]
302pub struct IndexedSearchProgress {
303 pub branches_visited: u64,
304 pub entries_seen: u64,
305 pub unique_items: u64,
306 pub active_time_ms: u64,
307 pub paused_time_ms: u64,
308 pub items_per_second: f64,
309 pub estimated_remaining_ms: Option<u64>,
310}
311
312#[derive(Debug, Clone, PartialEq)]
314pub struct SearchIndexStatus {
315 pub server: String,
316 pub state: SearchIndexState,
317 pub configured: bool,
318 pub active_generation: u64,
319 pub entry_count: u64,
320 pub unique_item_count: u64,
321 pub started_at: Option<String>,
322 pub completed_at: Option<String>,
323 pub last_error: Option<String>,
324 pub database_bytes: u64,
325 pub organization: NamespaceOrganization,
326 pub source: BrowseSource,
327 pub progress: Option<IndexedSearchProgress>,
328}
329
330#[derive(Debug, Clone, PartialEq, Eq)]
332pub struct IndexedSearchMatch {
333 pub item_id: String,
334 pub display_name: String,
335 pub kind: BrowseNodeKind,
336 pub breadcrumbs: Vec<String>,
337}
338
339#[derive(Debug, Clone, PartialEq)]
341pub struct SearchIndexResponse {
342 pub matches: Vec<IndexedSearchMatch>,
343 pub has_more: bool,
344 pub status: SearchIndexStatus,
345}
346
347#[derive(Debug, Clone, PartialEq, Eq)]
349pub struct BrowseBreadcrumb {
350 pub node_key: String,
351 pub display_name: String,
352}
353
354#[derive(Debug, Clone, PartialEq, Eq)]
356pub struct SearchMatch {
357 pub node: BrowseNode,
358 pub breadcrumbs: Vec<BrowseBreadcrumb>,
359}
360
361#[derive(Debug, Clone, PartialEq, Eq)]
363pub struct SearchProgress {
364 pub visited_nodes: u32,
365 pub matches: u32,
366 pub partial: bool,
367}
368
369#[derive(Debug, Clone, PartialEq, Eq)]
371pub struct SearchCompleted {
372 pub complete: bool,
373 pub cancelled: bool,
374 pub truncated: bool,
375 pub warning: Option<String>,
376}
377
378#[derive(Debug, Clone, PartialEq, Eq)]
380pub enum SearchEvent {
381 Match(SearchMatch),
382 Progress(SearchProgress),
383 Completed(SearchCompleted),
384}
385
386#[derive(Debug, Clone, PartialEq, Eq)]
391pub struct TagValue {
392 pub tag_id: String,
393 pub value: String,
394 pub quality: String,
395 pub timestamp: String,
396}
397
398#[derive(Debug, Clone, PartialEq, Eq)]
400pub struct WriteResult {
401 pub tag_id: String,
402 pub success: bool,
403 pub error: Option<String>,
404}
405
406#[derive(Debug, Clone, PartialEq)]
408pub enum Value {
409 String(String),
410 Int(i32),
411 Float(f64),
412 Bool(bool),
413}
414
415pub fn parse_value(raw: &str) -> Value {
417 if let Ok(b) = raw.parse::<bool>() {
418 return Value::Bool(b);
419 }
420 if let Ok(i) = raw.parse::<i32>() {
421 return Value::Int(i);
422 }
423 if let Ok(f) = raw.parse::<f64>() {
424 return Value::Float(f);
425 }
426 Value::String(raw.to_string())
427}
428
429fn invalid_enum(field: &str, value: i32) -> Error {
430 Error::Protocol(format!("gateway returned unknown {field} value {value}"))
431}
432
433fn organization(value: i32) -> Result<NamespaceOrganization> {
434 match proto::NamespaceOrganization::try_from(value)
435 .map_err(|_| invalid_enum("namespace organization", value))?
436 {
437 proto::NamespaceOrganization::Unspecified => Ok(NamespaceOrganization::Unspecified),
438 proto::NamespaceOrganization::Flat => Ok(NamespaceOrganization::Flat),
439 proto::NamespaceOrganization::Hierarchical => Ok(NamespaceOrganization::Hierarchical),
440 }
441}
442
443fn source(value: i32) -> Result<BrowseSource> {
444 match proto::BrowseSource::try_from(value).map_err(|_| invalid_enum("browse source", value))? {
445 proto::BrowseSource::Unspecified => Ok(BrowseSource::Unspecified),
446 proto::BrowseSource::Da3 => Ok(BrowseSource::Da3),
447 proto::BrowseSource::Da2 => Ok(BrowseSource::Da2),
448 proto::BrowseSource::Flat => Ok(BrowseSource::Flat),
449 proto::BrowseSource::Derived => Ok(BrowseSource::Derived),
450 }
451}
452
453fn node_kind(value: i32) -> Result<BrowseNodeKind> {
454 match proto::BrowseNodeKind::try_from(value)
455 .map_err(|_| invalid_enum("browse node kind", value))?
456 {
457 proto::BrowseNodeKind::Unspecified => Ok(BrowseNodeKind::Unspecified),
458 proto::BrowseNodeKind::Branch => Ok(BrowseNodeKind::Branch),
459 proto::BrowseNodeKind::Item => Ok(BrowseNodeKind::Item),
460 proto::BrowseNodeKind::BranchAndItem => Ok(BrowseNodeKind::BranchAndItem),
461 }
462}
463
464fn search_index_state(value: i32) -> Result<SearchIndexState> {
465 match proto::SearchIndexState::try_from(value)
466 .map_err(|_| invalid_enum("search index state", value))?
467 {
468 proto::SearchIndexState::Unspecified => Ok(SearchIndexState::Unspecified),
469 proto::SearchIndexState::NotIndexed => Ok(SearchIndexState::NotIndexed),
470 proto::SearchIndexState::Partial => Ok(SearchIndexState::Partial),
471 proto::SearchIndexState::Ready => Ok(SearchIndexState::Ready),
472 proto::SearchIndexState::Stale => Ok(SearchIndexState::Stale),
473 proto::SearchIndexState::Refreshing => Ok(SearchIndexState::Refreshing),
474 proto::SearchIndexState::Failed => Ok(SearchIndexState::Failed),
475 }
476}
477
478impl TryFrom<proto::GetCapabilitiesResponse> for Capabilities {
479 type Error = Error;
480
481 fn try_from(value: proto::GetCapabilitiesResponse) -> Result<Self> {
482 Ok(Self {
483 application_version: value.application_version,
484 protocol_version: value.protocol_version,
485 max_page_size: value.max_page_size,
486 supports_browse_sessions: value.supports_browse_sessions,
487 supports_search: value.supports_search,
488 organization: organization(value.organization)?,
489 source: source(value.source)?,
490 supports_indexed_search: value.supports_indexed_search,
491 indexed_search_protocol_version: value.indexed_search_protocol_version,
492 max_indexed_search_results: value.max_indexed_search_results,
493 search_index_state: search_index_state(value.search_index_state)?,
494 })
495 }
496}
497
498impl TryFrom<proto::BrowseNode> for BrowseNode {
499 type Error = Error;
500
501 fn try_from(value: proto::BrowseNode) -> Result<Self> {
502 let kind = node_kind(value.kind)?;
503 if kind.is_item() && value.item_id.is_none() {
504 return Err(Error::Protocol(
505 "gateway returned a selectable browse node without an ItemID".into(),
506 ));
507 }
508 if !kind.is_item() && value.item_id.is_some() {
509 return Err(Error::Protocol(
510 "gateway returned an ItemID for a non-selectable browse node".into(),
511 ));
512 }
513 Ok(Self {
514 node_key: value.node_key,
515 display_name: value.display_name,
516 kind,
517 item_id: value.item_id,
518 })
519 }
520}
521
522impl TryFrom<proto::BrowsePage> for BrowsePage {
523 type Error = Error;
524
525 fn try_from(value: proto::BrowsePage) -> Result<Self> {
526 if value.complete && value.next_page_token.is_some() {
527 return Err(Error::Protocol(
528 "gateway returned a complete browse page with a continuation token".into(),
529 ));
530 }
531 if !value.complete && value.next_page_token.is_none() {
532 return Err(Error::Protocol(
533 "gateway returned an incomplete browse page without a continuation token".into(),
534 ));
535 }
536 Ok(Self {
537 session_id: value.session_id,
538 nodes: value
539 .nodes
540 .into_iter()
541 .map(BrowseNode::try_from)
542 .collect::<Result<_>>()?,
543 next_page_token: value.next_page_token,
544 complete: value.complete,
545 organization: organization(value.organization)?,
546 source: source(value.source)?,
547 warning: value.warning,
548 })
549 }
550}
551
552impl From<BrowsePageRequest> for proto::BrowseRequest {
553 fn from(value: BrowsePageRequest) -> Self {
554 Self {
555 server: value.server,
556 session_id: value.session_id,
557 parent_node_key: value.parent_node_key,
558 page_token: value.page_token,
559 page_size: value.page_size,
560 refresh: value.refresh,
561 }
562 }
563}
564
565impl From<SearchRequest> for proto::SearchRequest {
566 fn from(value: SearchRequest) -> Self {
567 let match_mode = match value.match_mode {
568 SearchMatchMode::Exact => proto::SearchMatchMode::Exact,
569 SearchMatchMode::Prefix => proto::SearchMatchMode::Prefix,
570 SearchMatchMode::Contains => proto::SearchMatchMode::Contains,
571 };
572 Self {
573 server: value.server,
574 query: value.query,
575 match_mode: match_mode as i32,
576 session_id: value.session_id,
577 scope_node_key: value.scope_node_key,
578 max_results: value.max_results,
579 include_branches: value.include_branches,
580 refresh: value.refresh,
581 }
582 }
583}
584
585impl From<SearchIndexRequest> for proto::SearchIndexRequest {
586 fn from(value: SearchIndexRequest) -> Self {
587 let match_mode = match value.match_mode {
588 SearchMatchMode::Exact => proto::SearchMatchMode::Exact,
589 SearchMatchMode::Prefix => proto::SearchMatchMode::Prefix,
590 SearchMatchMode::Contains => proto::SearchMatchMode::Contains,
591 };
592 Self {
593 server: value.server,
594 query: value.query,
595 match_mode: match_mode as i32,
596 max_results: value.max_results,
597 }
598 }
599}
600
601impl From<SearchIndexControlAction> for proto::SearchIndexControlAction {
602 fn from(value: SearchIndexControlAction) -> Self {
603 match value {
604 SearchIndexControlAction::Pause => Self::Pause,
605 SearchIndexControlAction::Resume => Self::Resume,
606 SearchIndexControlAction::Cancel => Self::Cancel,
607 }
608 }
609}
610
611impl From<proto::IndexedSearchProgress> for IndexedSearchProgress {
612 fn from(value: proto::IndexedSearchProgress) -> Self {
613 Self {
614 branches_visited: value.branches_visited,
615 entries_seen: value.entries_seen,
616 unique_items: value.unique_items,
617 active_time_ms: value.active_time_ms,
618 paused_time_ms: value.paused_time_ms,
619 items_per_second: value.items_per_second,
620 estimated_remaining_ms: value.estimated_remaining_ms,
621 }
622 }
623}
624
625impl TryFrom<proto::SearchIndexStatus> for SearchIndexStatus {
626 type Error = Error;
627
628 fn try_from(value: proto::SearchIndexStatus) -> Result<Self> {
629 Ok(Self {
630 server: value.server,
631 state: search_index_state(value.state)?,
632 configured: value.configured,
633 active_generation: value.active_generation,
634 entry_count: value.entry_count,
635 unique_item_count: value.unique_item_count,
636 started_at: value.started_at,
637 completed_at: value.completed_at,
638 last_error: value.last_error,
639 database_bytes: value.database_bytes,
640 organization: organization(value.organization)?,
641 source: source(value.source)?,
642 progress: value.progress.map(Into::into),
643 })
644 }
645}
646
647impl TryFrom<proto::IndexedSearchMatch> for IndexedSearchMatch {
648 type Error = Error;
649
650 fn try_from(value: proto::IndexedSearchMatch) -> Result<Self> {
651 let kind = node_kind(value.kind)?;
652 if !kind.is_item() {
653 return Err(Error::Protocol(
654 "gateway returned a non-selectable indexed search match".into(),
655 ));
656 }
657 if value.item_id.is_empty() {
658 return Err(Error::Protocol(
659 "gateway returned an indexed search match without an ItemID".into(),
660 ));
661 }
662 Ok(Self {
663 item_id: value.item_id,
664 display_name: value.display_name,
665 kind,
666 breadcrumbs: value.breadcrumbs,
667 })
668 }
669}
670
671impl TryFrom<proto::SearchIndexResponse> for SearchIndexResponse {
672 type Error = Error;
673
674 fn try_from(value: proto::SearchIndexResponse) -> Result<Self> {
675 Ok(Self {
676 matches: value
677 .matches
678 .into_iter()
679 .map(IndexedSearchMatch::try_from)
680 .collect::<Result<_>>()?,
681 has_more: value.has_more,
682 status: value
683 .status
684 .ok_or_else(|| {
685 Error::Protocol("gateway returned indexed search results without status".into())
686 })?
687 .try_into()?,
688 })
689 }
690}
691
692impl TryFrom<proto::SearchEvent> for SearchEvent {
693 type Error = Error;
694
695 fn try_from(value: proto::SearchEvent) -> Result<Self> {
696 match value.event {
697 Some(proto::search_event::Event::Match(found)) => {
698 let node = found.node.ok_or_else(|| {
699 Error::Protocol("gateway returned a search match without a node".into())
700 })?;
701 Ok(Self::Match(SearchMatch {
702 node: node.try_into()?,
703 breadcrumbs: found
704 .breadcrumbs
705 .into_iter()
706 .map(|part| BrowseBreadcrumb {
707 node_key: part.node_key,
708 display_name: part.display_name,
709 })
710 .collect(),
711 }))
712 }
713 Some(proto::search_event::Event::Progress(progress)) => {
714 Ok(Self::Progress(SearchProgress {
715 visited_nodes: progress.visited_nodes,
716 matches: progress.matches,
717 partial: progress.partial,
718 }))
719 }
720 Some(proto::search_event::Event::Completed(completed)) => {
721 Ok(Self::Completed(SearchCompleted {
722 complete: completed.complete,
723 cancelled: completed.cancelled,
724 truncated: completed.truncated,
725 warning: completed.warning,
726 }))
727 }
728 None => Err(Error::Protocol(
729 "gateway returned an empty search event".into(),
730 )),
731 }
732 }
733}
734
735#[cfg(test)]
736mod tests {
737 use super::*;
738
739 #[test]
740 fn value_parsing_covers_all_variants() {
741 assert!(matches!(parse_value("true"), Value::Bool(true)));
742 assert!(matches!(parse_value("false"), Value::Bool(false)));
743 assert!(matches!(parse_value("42"), Value::Int(42)));
744 assert!(matches!(parse_value("-1"), Value::Int(-1)));
745 assert!(matches!(parse_value("9.5"), Value::Float(v) if v == 9.5));
746 assert!(matches!(parse_value("hello"), Value::String(v) if v == "hello"));
747 }
748
749 #[test]
750 fn enum_display_and_node_predicates_are_stable() {
751 assert_eq!(
752 NamespaceOrganization::Unspecified.to_string(),
753 "unspecified"
754 );
755 assert_eq!(NamespaceOrganization::Flat.to_string(), "flat");
756 assert_eq!(
757 NamespaceOrganization::Hierarchical.to_string(),
758 "hierarchical"
759 );
760 assert_eq!(BrowseSource::Unspecified.to_string(), "unspecified");
761 assert_eq!(BrowseSource::Da3.to_string(), "da3");
762 assert_eq!(BrowseSource::Da2.to_string(), "da2");
763 assert_eq!(BrowseSource::Flat.to_string(), "flat");
764 assert_eq!(BrowseSource::Derived.to_string(), "derived");
765 assert_eq!(BrowseNodeKind::Unspecified.to_string(), "unspecified");
766 assert_eq!(BrowseNodeKind::Branch.to_string(), "branch");
767 assert_eq!(BrowseNodeKind::Item.to_string(), "item");
768 assert_eq!(BrowseNodeKind::BranchAndItem.to_string(), "branch-and-item");
769 assert_eq!(SearchMatchMode::Exact.to_string(), "exact");
770 assert_eq!(SearchMatchMode::Prefix.to_string(), "prefix");
771 assert_eq!(SearchMatchMode::Contains.to_string(), "contains");
772 assert_eq!(SearchIndexState::Unspecified.to_string(), "unspecified");
773 assert_eq!(SearchIndexState::NotIndexed.to_string(), "not-indexed");
774 assert_eq!(SearchIndexState::Partial.to_string(), "partial");
775 assert_eq!(SearchIndexState::Ready.to_string(), "ready");
776 assert_eq!(SearchIndexState::Stale.to_string(), "stale");
777 assert_eq!(SearchIndexState::Refreshing.to_string(), "refreshing");
778 assert_eq!(SearchIndexState::Failed.to_string(), "failed");
779 assert!(BrowseNodeKind::Branch.is_branch());
780 assert!(!BrowseNodeKind::Branch.is_item());
781 assert!(BrowseNodeKind::Item.is_item());
782 assert!(!BrowseNodeKind::Item.is_branch());
783 assert!(BrowseNodeKind::BranchAndItem.is_branch());
784 assert!(BrowseNodeKind::BranchAndItem.is_item());
785 assert!(!BrowseNodeKind::Unspecified.is_branch());
786 assert!(!BrowseNodeKind::Unspecified.is_item());
787 }
788
789 #[test]
790 fn browse_request_builders_map_all_fields() {
791 let root = BrowsePageRequest::root("S", 20).with_refresh(true);
792 assert_eq!(root.server, "S");
793 assert_eq!(root.page_size, 20);
794 assert!(root.refresh);
795
796 let children = BrowsePageRequest::children("S", "session", "node", 30);
797 assert_eq!(children.session_id.as_deref(), Some("session"));
798 assert_eq!(children.parent_node_key.as_deref(), Some("node"));
799
800 let next = BrowsePageRequest::next("S", "session", Some("node".into()), "token", 40);
801 let proto: proto::BrowseRequest = next.into();
802 assert_eq!(proto.page_token.as_deref(), Some("token"));
803 assert_eq!(proto.page_size, 40);
804 }
805
806 #[test]
807 fn search_request_defaults_and_mapping_are_typed() {
808 for (mode, expected) in [
809 (SearchMatchMode::Exact, proto::SearchMatchMode::Exact),
810 (SearchMatchMode::Prefix, proto::SearchMatchMode::Prefix),
811 (SearchMatchMode::Contains, proto::SearchMatchMode::Contains),
812 ] {
813 let request = SearchRequest::new("S", "query", mode);
814 assert_eq!(request.max_results, DEFAULT_SEARCH_MAX_RESULTS);
815 let mapped: proto::SearchRequest = request.into();
816 assert_eq!(mapped.match_mode, expected as i32);
817 }
818 }
819
820 #[test]
821 fn indexed_search_request_and_controls_map_all_variants() {
822 for (mode, expected) in [
823 (SearchMatchMode::Exact, proto::SearchMatchMode::Exact),
824 (SearchMatchMode::Prefix, proto::SearchMatchMode::Prefix),
825 (SearchMatchMode::Contains, proto::SearchMatchMode::Contains),
826 ] {
827 let request = SearchIndexRequest::new("S", "query", mode);
828 assert_eq!(request.max_results, DEFAULT_INDEX_SEARCH_MAX_RESULTS);
829 let mapped: proto::SearchIndexRequest = request.into();
830 assert_eq!(mapped.match_mode, expected as i32);
831 }
832 for (action, expected) in [
833 (
834 SearchIndexControlAction::Pause,
835 proto::SearchIndexControlAction::Pause,
836 ),
837 (
838 SearchIndexControlAction::Resume,
839 proto::SearchIndexControlAction::Resume,
840 ),
841 (
842 SearchIndexControlAction::Cancel,
843 proto::SearchIndexControlAction::Cancel,
844 ),
845 ] {
846 assert_eq!(proto::SearchIndexControlAction::from(action), expected);
847 }
848 }
849
850 #[test]
851 fn invalid_and_inconsistent_proto_values_are_rejected() {
852 assert_eq!(
853 organization(proto::NamespaceOrganization::Unspecified as i32).unwrap(),
854 NamespaceOrganization::Unspecified
855 );
856 assert_eq!(
857 organization(proto::NamespaceOrganization::Flat as i32).unwrap(),
858 NamespaceOrganization::Flat
859 );
860 assert_eq!(
861 organization(proto::NamespaceOrganization::Hierarchical as i32).unwrap(),
862 NamespaceOrganization::Hierarchical
863 );
864 assert_eq!(
865 source(proto::BrowseSource::Unspecified as i32).unwrap(),
866 BrowseSource::Unspecified
867 );
868 assert_eq!(
869 source(proto::BrowseSource::Da3 as i32).unwrap(),
870 BrowseSource::Da3
871 );
872 assert_eq!(
873 source(proto::BrowseSource::Da2 as i32).unwrap(),
874 BrowseSource::Da2
875 );
876 assert_eq!(
877 source(proto::BrowseSource::Flat as i32).unwrap(),
878 BrowseSource::Flat
879 );
880 assert_eq!(
881 source(proto::BrowseSource::Derived as i32).unwrap(),
882 BrowseSource::Derived
883 );
884 assert_eq!(
885 node_kind(proto::BrowseNodeKind::Unspecified as i32).unwrap(),
886 BrowseNodeKind::Unspecified
887 );
888 assert_eq!(
889 node_kind(proto::BrowseNodeKind::Branch as i32).unwrap(),
890 BrowseNodeKind::Branch
891 );
892 assert_eq!(
893 node_kind(proto::BrowseNodeKind::Item as i32).unwrap(),
894 BrowseNodeKind::Item
895 );
896 assert_eq!(
897 node_kind(proto::BrowseNodeKind::BranchAndItem as i32).unwrap(),
898 BrowseNodeKind::BranchAndItem
899 );
900 assert!(matches!(organization(99), Err(Error::Protocol(_))));
901 assert!(matches!(source(99), Err(Error::Protocol(_))));
902 assert!(matches!(node_kind(99), Err(Error::Protocol(_))));
903 for (proto_state, state) in [
904 (
905 proto::SearchIndexState::Unspecified,
906 SearchIndexState::Unspecified,
907 ),
908 (
909 proto::SearchIndexState::NotIndexed,
910 SearchIndexState::NotIndexed,
911 ),
912 (proto::SearchIndexState::Partial, SearchIndexState::Partial),
913 (proto::SearchIndexState::Ready, SearchIndexState::Ready),
914 (proto::SearchIndexState::Stale, SearchIndexState::Stale),
915 (
916 proto::SearchIndexState::Refreshing,
917 SearchIndexState::Refreshing,
918 ),
919 (proto::SearchIndexState::Failed, SearchIndexState::Failed),
920 ] {
921 assert_eq!(search_index_state(proto_state as i32).unwrap(), state);
922 }
923 assert!(matches!(search_index_state(99), Err(Error::Protocol(_))));
924
925 let missing_item_id = proto::BrowseNode {
926 kind: proto::BrowseNodeKind::Item as i32,
927 ..Default::default()
928 };
929 assert!(matches!(
930 BrowseNode::try_from(missing_item_id),
931 Err(Error::Protocol(_))
932 ));
933 let unexpected_item_id = proto::BrowseNode {
934 kind: proto::BrowseNodeKind::Branch as i32,
935 item_id: Some("not-valid".into()),
936 ..Default::default()
937 };
938 assert!(matches!(
939 BrowseNode::try_from(unexpected_item_id),
940 Err(Error::Protocol(_))
941 ));
942
943 let complete_with_token = proto::BrowsePage {
944 complete: true,
945 next_page_token: Some("token".into()),
946 ..Default::default()
947 };
948 assert!(matches!(
949 BrowsePage::try_from(complete_with_token),
950 Err(Error::Protocol(_))
951 ));
952
953 let incomplete_without_token = proto::BrowsePage::default();
954 assert!(matches!(
955 BrowsePage::try_from(incomplete_without_token),
956 Err(Error::Protocol(_))
957 ));
958 }
959
960 #[test]
961 fn search_event_conversion_covers_every_event() {
962 let found = proto::SearchEvent {
963 event: Some(proto::search_event::Event::Match(proto::SearchMatch {
964 node: Some(proto::BrowseNode {
965 node_key: "n".into(),
966 display_name: "PV".into(),
967 kind: proto::BrowseNodeKind::Item as i32,
968 item_id: Some("FCS!TAG.PV".into()),
969 }),
970 breadcrumbs: vec![proto::BrowseBreadcrumb {
971 node_key: "root".into(),
972 display_name: "FCS".into(),
973 }],
974 })),
975 };
976 assert!(matches!(
977 SearchEvent::try_from(found).unwrap(),
978 SearchEvent::Match(_)
979 ));
980
981 let progress = proto::SearchEvent {
982 event: Some(proto::search_event::Event::Progress(
983 proto::SearchProgress {
984 visited_nodes: 10,
985 matches: 2,
986 partial: true,
987 },
988 )),
989 };
990 assert!(matches!(
991 SearchEvent::try_from(progress).unwrap(),
992 SearchEvent::Progress(_)
993 ));
994
995 let completed = proto::SearchEvent {
996 event: Some(proto::search_event::Event::Completed(
997 proto::SearchCompleted {
998 complete: true,
999 cancelled: false,
1000 truncated: false,
1001 warning: None,
1002 },
1003 )),
1004 };
1005 assert!(matches!(
1006 SearchEvent::try_from(completed).unwrap(),
1007 SearchEvent::Completed(_)
1008 ));
1009
1010 assert!(matches!(
1011 SearchEvent::try_from(proto::SearchEvent::default()),
1012 Err(Error::Protocol(_))
1013 ));
1014 let missing_node = proto::SearchEvent {
1015 event: Some(proto::search_event::Event::Match(
1016 proto::SearchMatch::default(),
1017 )),
1018 };
1019 assert!(matches!(
1020 SearchEvent::try_from(missing_node),
1021 Err(Error::Protocol(_))
1022 ));
1023 }
1024
1025 #[test]
1026 fn indexed_search_response_preserves_identity_and_status() {
1027 let response = proto::SearchIndexResponse {
1028 matches: vec![proto::IndexedSearchMatch {
1029 item_id: "FCS0201!204FI00510.PV".into(),
1030 display_name: "PV".into(),
1031 kind: proto::BrowseNodeKind::BranchAndItem as i32,
1032 breadcrumbs: vec!["FCS0201".into(), "204FI00510".into()],
1033 }],
1034 has_more: true,
1035 status: Some(proto::SearchIndexStatus {
1036 server: "Yokogawa.CSHIS_OPC.1".into(),
1037 state: proto::SearchIndexState::Refreshing as i32,
1038 configured: true,
1039 active_generation: 7,
1040 entry_count: 100_001,
1041 unique_item_count: 100_000,
1042 started_at: Some("start".into()),
1043 completed_at: Some("complete".into()),
1044 last_error: Some("prior error".into()),
1045 database_bytes: 4096,
1046 organization: proto::NamespaceOrganization::Hierarchical as i32,
1047 source: proto::BrowseSource::Da2 as i32,
1048 progress: Some(proto::IndexedSearchProgress {
1049 branches_visited: 10,
1050 entries_seen: 20,
1051 unique_items: 19,
1052 active_time_ms: 30,
1053 paused_time_ms: 40,
1054 items_per_second: 12.5,
1055 estimated_remaining_ms: Some(50),
1056 }),
1057 }),
1058 };
1059 let typed = SearchIndexResponse::try_from(response).unwrap();
1060 assert_eq!(typed.matches[0].item_id, "FCS0201!204FI00510.PV");
1061 assert_eq!(typed.matches[0].kind, BrowseNodeKind::BranchAndItem);
1062 assert_eq!(typed.status.state, SearchIndexState::Refreshing);
1063 assert_eq!(
1064 typed
1065 .status
1066 .progress
1067 .as_ref()
1068 .unwrap()
1069 .estimated_remaining_ms,
1070 Some(50)
1071 );
1072 assert!(typed.has_more);
1073
1074 let item = IndexedSearchMatch::try_from(proto::IndexedSearchMatch {
1075 kind: proto::BrowseNodeKind::Item as i32,
1076 item_id: "id".into(),
1077 ..Default::default()
1078 })
1079 .unwrap();
1080 assert_eq!(item.kind, BrowseNodeKind::Item);
1081 assert!(matches!(
1082 IndexedSearchMatch::try_from(proto::IndexedSearchMatch {
1083 kind: proto::BrowseNodeKind::Item as i32,
1084 ..Default::default()
1085 }),
1086 Err(Error::Protocol(_))
1087 ));
1088
1089 assert!(matches!(
1090 IndexedSearchMatch::try_from(proto::IndexedSearchMatch {
1091 kind: proto::BrowseNodeKind::Branch as i32,
1092 ..Default::default()
1093 }),
1094 Err(Error::Protocol(_))
1095 ));
1096 assert!(matches!(
1097 SearchIndexResponse::try_from(proto::SearchIndexResponse::default()),
1098 Err(Error::Protocol(_))
1099 ));
1100 }
1101}