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)]
388pub struct TagValue {
389 pub tag_id: String,
390 pub value: String,
391 pub quality: String,
392 pub timestamp: String,
393}
394
395#[derive(Debug, Clone, PartialEq, Eq)]
397pub struct WriteResult {
398 pub tag_id: String,
399 pub success: bool,
400 pub error: Option<String>,
401}
402
403#[derive(Debug, Clone, PartialEq)]
405pub enum Value {
406 String(String),
407 Int(i32),
408 Float(f64),
409 Bool(bool),
410}
411
412pub fn parse_value(raw: &str) -> Value {
414 if let Ok(b) = raw.parse::<bool>() {
415 return Value::Bool(b);
416 }
417 if let Ok(i) = raw.parse::<i32>() {
418 return Value::Int(i);
419 }
420 if let Ok(f) = raw.parse::<f64>() {
421 return Value::Float(f);
422 }
423 Value::String(raw.to_string())
424}
425
426fn invalid_enum(field: &str, value: i32) -> Error {
427 Error::Protocol(format!("gateway returned unknown {field} value {value}"))
428}
429
430fn organization(value: i32) -> Result<NamespaceOrganization> {
431 match proto::NamespaceOrganization::try_from(value)
432 .map_err(|_| invalid_enum("namespace organization", value))?
433 {
434 proto::NamespaceOrganization::Unspecified => Ok(NamespaceOrganization::Unspecified),
435 proto::NamespaceOrganization::Flat => Ok(NamespaceOrganization::Flat),
436 proto::NamespaceOrganization::Hierarchical => Ok(NamespaceOrganization::Hierarchical),
437 }
438}
439
440fn source(value: i32) -> Result<BrowseSource> {
441 match proto::BrowseSource::try_from(value).map_err(|_| invalid_enum("browse source", value))? {
442 proto::BrowseSource::Unspecified => Ok(BrowseSource::Unspecified),
443 proto::BrowseSource::Da3 => Ok(BrowseSource::Da3),
444 proto::BrowseSource::Da2 => Ok(BrowseSource::Da2),
445 proto::BrowseSource::Flat => Ok(BrowseSource::Flat),
446 proto::BrowseSource::Derived => Ok(BrowseSource::Derived),
447 }
448}
449
450fn node_kind(value: i32) -> Result<BrowseNodeKind> {
451 match proto::BrowseNodeKind::try_from(value)
452 .map_err(|_| invalid_enum("browse node kind", value))?
453 {
454 proto::BrowseNodeKind::Unspecified => Ok(BrowseNodeKind::Unspecified),
455 proto::BrowseNodeKind::Branch => Ok(BrowseNodeKind::Branch),
456 proto::BrowseNodeKind::Item => Ok(BrowseNodeKind::Item),
457 proto::BrowseNodeKind::BranchAndItem => Ok(BrowseNodeKind::BranchAndItem),
458 }
459}
460
461fn search_index_state(value: i32) -> Result<SearchIndexState> {
462 match proto::SearchIndexState::try_from(value)
463 .map_err(|_| invalid_enum("search index state", value))?
464 {
465 proto::SearchIndexState::Unspecified => Ok(SearchIndexState::Unspecified),
466 proto::SearchIndexState::NotIndexed => Ok(SearchIndexState::NotIndexed),
467 proto::SearchIndexState::Partial => Ok(SearchIndexState::Partial),
468 proto::SearchIndexState::Ready => Ok(SearchIndexState::Ready),
469 proto::SearchIndexState::Stale => Ok(SearchIndexState::Stale),
470 proto::SearchIndexState::Refreshing => Ok(SearchIndexState::Refreshing),
471 proto::SearchIndexState::Failed => Ok(SearchIndexState::Failed),
472 }
473}
474
475impl TryFrom<proto::GetCapabilitiesResponse> for Capabilities {
476 type Error = Error;
477
478 fn try_from(value: proto::GetCapabilitiesResponse) -> Result<Self> {
479 Ok(Self {
480 application_version: value.application_version,
481 protocol_version: value.protocol_version,
482 max_page_size: value.max_page_size,
483 supports_browse_sessions: value.supports_browse_sessions,
484 supports_search: value.supports_search,
485 organization: organization(value.organization)?,
486 source: source(value.source)?,
487 supports_indexed_search: value.supports_indexed_search,
488 indexed_search_protocol_version: value.indexed_search_protocol_version,
489 max_indexed_search_results: value.max_indexed_search_results,
490 search_index_state: search_index_state(value.search_index_state)?,
491 })
492 }
493}
494
495impl TryFrom<proto::BrowseNode> for BrowseNode {
496 type Error = Error;
497
498 fn try_from(value: proto::BrowseNode) -> Result<Self> {
499 let kind = node_kind(value.kind)?;
500 if kind.is_item() && value.item_id.is_none() {
501 return Err(Error::Protocol(
502 "gateway returned a selectable browse node without an ItemID".into(),
503 ));
504 }
505 if !kind.is_item() && value.item_id.is_some() {
506 return Err(Error::Protocol(
507 "gateway returned an ItemID for a non-selectable browse node".into(),
508 ));
509 }
510 Ok(Self {
511 node_key: value.node_key,
512 display_name: value.display_name,
513 kind,
514 item_id: value.item_id,
515 })
516 }
517}
518
519impl TryFrom<proto::BrowsePage> for BrowsePage {
520 type Error = Error;
521
522 fn try_from(value: proto::BrowsePage) -> Result<Self> {
523 if value.complete && value.next_page_token.is_some() {
524 return Err(Error::Protocol(
525 "gateway returned a complete browse page with a continuation token".into(),
526 ));
527 }
528 if !value.complete && value.next_page_token.is_none() {
529 return Err(Error::Protocol(
530 "gateway returned an incomplete browse page without a continuation token".into(),
531 ));
532 }
533 Ok(Self {
534 session_id: value.session_id,
535 nodes: value
536 .nodes
537 .into_iter()
538 .map(BrowseNode::try_from)
539 .collect::<Result<_>>()?,
540 next_page_token: value.next_page_token,
541 complete: value.complete,
542 organization: organization(value.organization)?,
543 source: source(value.source)?,
544 warning: value.warning,
545 })
546 }
547}
548
549impl From<BrowsePageRequest> for proto::BrowseRequest {
550 fn from(value: BrowsePageRequest) -> Self {
551 Self {
552 server: value.server,
553 session_id: value.session_id,
554 parent_node_key: value.parent_node_key,
555 page_token: value.page_token,
556 page_size: value.page_size,
557 refresh: value.refresh,
558 }
559 }
560}
561
562impl From<SearchRequest> for proto::SearchRequest {
563 fn from(value: SearchRequest) -> Self {
564 let match_mode = match value.match_mode {
565 SearchMatchMode::Exact => proto::SearchMatchMode::Exact,
566 SearchMatchMode::Prefix => proto::SearchMatchMode::Prefix,
567 SearchMatchMode::Contains => proto::SearchMatchMode::Contains,
568 };
569 Self {
570 server: value.server,
571 query: value.query,
572 match_mode: match_mode as i32,
573 session_id: value.session_id,
574 scope_node_key: value.scope_node_key,
575 max_results: value.max_results,
576 include_branches: value.include_branches,
577 refresh: value.refresh,
578 }
579 }
580}
581
582impl From<SearchIndexRequest> for proto::SearchIndexRequest {
583 fn from(value: SearchIndexRequest) -> Self {
584 let match_mode = match value.match_mode {
585 SearchMatchMode::Exact => proto::SearchMatchMode::Exact,
586 SearchMatchMode::Prefix => proto::SearchMatchMode::Prefix,
587 SearchMatchMode::Contains => proto::SearchMatchMode::Contains,
588 };
589 Self {
590 server: value.server,
591 query: value.query,
592 match_mode: match_mode as i32,
593 max_results: value.max_results,
594 }
595 }
596}
597
598impl From<SearchIndexControlAction> for proto::SearchIndexControlAction {
599 fn from(value: SearchIndexControlAction) -> Self {
600 match value {
601 SearchIndexControlAction::Pause => Self::Pause,
602 SearchIndexControlAction::Resume => Self::Resume,
603 SearchIndexControlAction::Cancel => Self::Cancel,
604 }
605 }
606}
607
608impl From<proto::IndexedSearchProgress> for IndexedSearchProgress {
609 fn from(value: proto::IndexedSearchProgress) -> Self {
610 Self {
611 branches_visited: value.branches_visited,
612 entries_seen: value.entries_seen,
613 unique_items: value.unique_items,
614 active_time_ms: value.active_time_ms,
615 paused_time_ms: value.paused_time_ms,
616 items_per_second: value.items_per_second,
617 estimated_remaining_ms: value.estimated_remaining_ms,
618 }
619 }
620}
621
622impl TryFrom<proto::SearchIndexStatus> for SearchIndexStatus {
623 type Error = Error;
624
625 fn try_from(value: proto::SearchIndexStatus) -> Result<Self> {
626 Ok(Self {
627 server: value.server,
628 state: search_index_state(value.state)?,
629 configured: value.configured,
630 active_generation: value.active_generation,
631 entry_count: value.entry_count,
632 unique_item_count: value.unique_item_count,
633 started_at: value.started_at,
634 completed_at: value.completed_at,
635 last_error: value.last_error,
636 database_bytes: value.database_bytes,
637 organization: organization(value.organization)?,
638 source: source(value.source)?,
639 progress: value.progress.map(Into::into),
640 })
641 }
642}
643
644impl TryFrom<proto::IndexedSearchMatch> for IndexedSearchMatch {
645 type Error = Error;
646
647 fn try_from(value: proto::IndexedSearchMatch) -> Result<Self> {
648 let kind = node_kind(value.kind)?;
649 if !kind.is_item() {
650 return Err(Error::Protocol(
651 "gateway returned a non-selectable indexed search match".into(),
652 ));
653 }
654 if value.item_id.is_empty() {
655 return Err(Error::Protocol(
656 "gateway returned an indexed search match without an ItemID".into(),
657 ));
658 }
659 Ok(Self {
660 item_id: value.item_id,
661 display_name: value.display_name,
662 kind,
663 breadcrumbs: value.breadcrumbs,
664 })
665 }
666}
667
668impl TryFrom<proto::SearchIndexResponse> for SearchIndexResponse {
669 type Error = Error;
670
671 fn try_from(value: proto::SearchIndexResponse) -> Result<Self> {
672 Ok(Self {
673 matches: value
674 .matches
675 .into_iter()
676 .map(IndexedSearchMatch::try_from)
677 .collect::<Result<_>>()?,
678 has_more: value.has_more,
679 status: value
680 .status
681 .ok_or_else(|| {
682 Error::Protocol("gateway returned indexed search results without status".into())
683 })?
684 .try_into()?,
685 })
686 }
687}
688
689impl TryFrom<proto::SearchEvent> for SearchEvent {
690 type Error = Error;
691
692 fn try_from(value: proto::SearchEvent) -> Result<Self> {
693 match value.event {
694 Some(proto::search_event::Event::Match(found)) => {
695 let node = found.node.ok_or_else(|| {
696 Error::Protocol("gateway returned a search match without a node".into())
697 })?;
698 Ok(Self::Match(SearchMatch {
699 node: node.try_into()?,
700 breadcrumbs: found
701 .breadcrumbs
702 .into_iter()
703 .map(|part| BrowseBreadcrumb {
704 node_key: part.node_key,
705 display_name: part.display_name,
706 })
707 .collect(),
708 }))
709 }
710 Some(proto::search_event::Event::Progress(progress)) => {
711 Ok(Self::Progress(SearchProgress {
712 visited_nodes: progress.visited_nodes,
713 matches: progress.matches,
714 partial: progress.partial,
715 }))
716 }
717 Some(proto::search_event::Event::Completed(completed)) => {
718 Ok(Self::Completed(SearchCompleted {
719 complete: completed.complete,
720 cancelled: completed.cancelled,
721 truncated: completed.truncated,
722 warning: completed.warning,
723 }))
724 }
725 None => Err(Error::Protocol(
726 "gateway returned an empty search event".into(),
727 )),
728 }
729 }
730}
731
732#[cfg(test)]
733mod tests {
734 use super::*;
735
736 #[test]
737 fn value_parsing_covers_all_variants() {
738 assert!(matches!(parse_value("true"), Value::Bool(true)));
739 assert!(matches!(parse_value("false"), Value::Bool(false)));
740 assert!(matches!(parse_value("42"), Value::Int(42)));
741 assert!(matches!(parse_value("-1"), Value::Int(-1)));
742 assert!(matches!(parse_value("9.5"), Value::Float(v) if v == 9.5));
743 assert!(matches!(parse_value("hello"), Value::String(v) if v == "hello"));
744 }
745
746 #[test]
747 fn enum_display_and_node_predicates_are_stable() {
748 assert_eq!(
749 NamespaceOrganization::Unspecified.to_string(),
750 "unspecified"
751 );
752 assert_eq!(NamespaceOrganization::Flat.to_string(), "flat");
753 assert_eq!(
754 NamespaceOrganization::Hierarchical.to_string(),
755 "hierarchical"
756 );
757 assert_eq!(BrowseSource::Unspecified.to_string(), "unspecified");
758 assert_eq!(BrowseSource::Da3.to_string(), "da3");
759 assert_eq!(BrowseSource::Da2.to_string(), "da2");
760 assert_eq!(BrowseSource::Flat.to_string(), "flat");
761 assert_eq!(BrowseSource::Derived.to_string(), "derived");
762 assert_eq!(BrowseNodeKind::Unspecified.to_string(), "unspecified");
763 assert_eq!(BrowseNodeKind::Branch.to_string(), "branch");
764 assert_eq!(BrowseNodeKind::Item.to_string(), "item");
765 assert_eq!(BrowseNodeKind::BranchAndItem.to_string(), "branch-and-item");
766 assert_eq!(SearchMatchMode::Exact.to_string(), "exact");
767 assert_eq!(SearchMatchMode::Prefix.to_string(), "prefix");
768 assert_eq!(SearchMatchMode::Contains.to_string(), "contains");
769 assert_eq!(SearchIndexState::Unspecified.to_string(), "unspecified");
770 assert_eq!(SearchIndexState::NotIndexed.to_string(), "not-indexed");
771 assert_eq!(SearchIndexState::Partial.to_string(), "partial");
772 assert_eq!(SearchIndexState::Ready.to_string(), "ready");
773 assert_eq!(SearchIndexState::Stale.to_string(), "stale");
774 assert_eq!(SearchIndexState::Refreshing.to_string(), "refreshing");
775 assert_eq!(SearchIndexState::Failed.to_string(), "failed");
776 assert!(BrowseNodeKind::Branch.is_branch());
777 assert!(!BrowseNodeKind::Branch.is_item());
778 assert!(BrowseNodeKind::Item.is_item());
779 assert!(!BrowseNodeKind::Item.is_branch());
780 assert!(BrowseNodeKind::BranchAndItem.is_branch());
781 assert!(BrowseNodeKind::BranchAndItem.is_item());
782 assert!(!BrowseNodeKind::Unspecified.is_branch());
783 assert!(!BrowseNodeKind::Unspecified.is_item());
784 }
785
786 #[test]
787 fn browse_request_builders_map_all_fields() {
788 let root = BrowsePageRequest::root("S", 20).with_refresh(true);
789 assert_eq!(root.server, "S");
790 assert_eq!(root.page_size, 20);
791 assert!(root.refresh);
792
793 let children = BrowsePageRequest::children("S", "session", "node", 30);
794 assert_eq!(children.session_id.as_deref(), Some("session"));
795 assert_eq!(children.parent_node_key.as_deref(), Some("node"));
796
797 let next = BrowsePageRequest::next("S", "session", Some("node".into()), "token", 40);
798 let proto: proto::BrowseRequest = next.into();
799 assert_eq!(proto.page_token.as_deref(), Some("token"));
800 assert_eq!(proto.page_size, 40);
801 }
802
803 #[test]
804 fn search_request_defaults_and_mapping_are_typed() {
805 for (mode, expected) in [
806 (SearchMatchMode::Exact, proto::SearchMatchMode::Exact),
807 (SearchMatchMode::Prefix, proto::SearchMatchMode::Prefix),
808 (SearchMatchMode::Contains, proto::SearchMatchMode::Contains),
809 ] {
810 let request = SearchRequest::new("S", "query", mode);
811 assert_eq!(request.max_results, DEFAULT_SEARCH_MAX_RESULTS);
812 let mapped: proto::SearchRequest = request.into();
813 assert_eq!(mapped.match_mode, expected as i32);
814 }
815 }
816
817 #[test]
818 fn indexed_search_request_and_controls_map_all_variants() {
819 for (mode, expected) in [
820 (SearchMatchMode::Exact, proto::SearchMatchMode::Exact),
821 (SearchMatchMode::Prefix, proto::SearchMatchMode::Prefix),
822 (SearchMatchMode::Contains, proto::SearchMatchMode::Contains),
823 ] {
824 let request = SearchIndexRequest::new("S", "query", mode);
825 assert_eq!(request.max_results, DEFAULT_INDEX_SEARCH_MAX_RESULTS);
826 let mapped: proto::SearchIndexRequest = request.into();
827 assert_eq!(mapped.match_mode, expected as i32);
828 }
829 for (action, expected) in [
830 (
831 SearchIndexControlAction::Pause,
832 proto::SearchIndexControlAction::Pause,
833 ),
834 (
835 SearchIndexControlAction::Resume,
836 proto::SearchIndexControlAction::Resume,
837 ),
838 (
839 SearchIndexControlAction::Cancel,
840 proto::SearchIndexControlAction::Cancel,
841 ),
842 ] {
843 assert_eq!(proto::SearchIndexControlAction::from(action), expected);
844 }
845 }
846
847 #[test]
848 fn invalid_and_inconsistent_proto_values_are_rejected() {
849 assert_eq!(
850 organization(proto::NamespaceOrganization::Unspecified as i32).unwrap(),
851 NamespaceOrganization::Unspecified
852 );
853 assert_eq!(
854 organization(proto::NamespaceOrganization::Flat as i32).unwrap(),
855 NamespaceOrganization::Flat
856 );
857 assert_eq!(
858 organization(proto::NamespaceOrganization::Hierarchical as i32).unwrap(),
859 NamespaceOrganization::Hierarchical
860 );
861 assert_eq!(
862 source(proto::BrowseSource::Unspecified as i32).unwrap(),
863 BrowseSource::Unspecified
864 );
865 assert_eq!(
866 source(proto::BrowseSource::Da3 as i32).unwrap(),
867 BrowseSource::Da3
868 );
869 assert_eq!(
870 source(proto::BrowseSource::Da2 as i32).unwrap(),
871 BrowseSource::Da2
872 );
873 assert_eq!(
874 source(proto::BrowseSource::Flat as i32).unwrap(),
875 BrowseSource::Flat
876 );
877 assert_eq!(
878 source(proto::BrowseSource::Derived as i32).unwrap(),
879 BrowseSource::Derived
880 );
881 assert_eq!(
882 node_kind(proto::BrowseNodeKind::Unspecified as i32).unwrap(),
883 BrowseNodeKind::Unspecified
884 );
885 assert_eq!(
886 node_kind(proto::BrowseNodeKind::Branch as i32).unwrap(),
887 BrowseNodeKind::Branch
888 );
889 assert_eq!(
890 node_kind(proto::BrowseNodeKind::Item as i32).unwrap(),
891 BrowseNodeKind::Item
892 );
893 assert_eq!(
894 node_kind(proto::BrowseNodeKind::BranchAndItem as i32).unwrap(),
895 BrowseNodeKind::BranchAndItem
896 );
897 assert!(matches!(organization(99), Err(Error::Protocol(_))));
898 assert!(matches!(source(99), Err(Error::Protocol(_))));
899 assert!(matches!(node_kind(99), Err(Error::Protocol(_))));
900 for (proto_state, state) in [
901 (
902 proto::SearchIndexState::Unspecified,
903 SearchIndexState::Unspecified,
904 ),
905 (
906 proto::SearchIndexState::NotIndexed,
907 SearchIndexState::NotIndexed,
908 ),
909 (proto::SearchIndexState::Partial, SearchIndexState::Partial),
910 (proto::SearchIndexState::Ready, SearchIndexState::Ready),
911 (proto::SearchIndexState::Stale, SearchIndexState::Stale),
912 (
913 proto::SearchIndexState::Refreshing,
914 SearchIndexState::Refreshing,
915 ),
916 (proto::SearchIndexState::Failed, SearchIndexState::Failed),
917 ] {
918 assert_eq!(search_index_state(proto_state as i32).unwrap(), state);
919 }
920 assert!(matches!(search_index_state(99), Err(Error::Protocol(_))));
921
922 let missing_item_id = proto::BrowseNode {
923 kind: proto::BrowseNodeKind::Item as i32,
924 ..Default::default()
925 };
926 assert!(matches!(
927 BrowseNode::try_from(missing_item_id),
928 Err(Error::Protocol(_))
929 ));
930 let unexpected_item_id = proto::BrowseNode {
931 kind: proto::BrowseNodeKind::Branch as i32,
932 item_id: Some("not-valid".into()),
933 ..Default::default()
934 };
935 assert!(matches!(
936 BrowseNode::try_from(unexpected_item_id),
937 Err(Error::Protocol(_))
938 ));
939
940 let complete_with_token = proto::BrowsePage {
941 complete: true,
942 next_page_token: Some("token".into()),
943 ..Default::default()
944 };
945 assert!(matches!(
946 BrowsePage::try_from(complete_with_token),
947 Err(Error::Protocol(_))
948 ));
949
950 let incomplete_without_token = proto::BrowsePage::default();
951 assert!(matches!(
952 BrowsePage::try_from(incomplete_without_token),
953 Err(Error::Protocol(_))
954 ));
955 }
956
957 #[test]
958 fn search_event_conversion_covers_every_event() {
959 let found = proto::SearchEvent {
960 event: Some(proto::search_event::Event::Match(proto::SearchMatch {
961 node: Some(proto::BrowseNode {
962 node_key: "n".into(),
963 display_name: "PV".into(),
964 kind: proto::BrowseNodeKind::Item as i32,
965 item_id: Some("FCS!TAG.PV".into()),
966 }),
967 breadcrumbs: vec![proto::BrowseBreadcrumb {
968 node_key: "root".into(),
969 display_name: "FCS".into(),
970 }],
971 })),
972 };
973 assert!(matches!(
974 SearchEvent::try_from(found).unwrap(),
975 SearchEvent::Match(_)
976 ));
977
978 let progress = proto::SearchEvent {
979 event: Some(proto::search_event::Event::Progress(
980 proto::SearchProgress {
981 visited_nodes: 10,
982 matches: 2,
983 partial: true,
984 },
985 )),
986 };
987 assert!(matches!(
988 SearchEvent::try_from(progress).unwrap(),
989 SearchEvent::Progress(_)
990 ));
991
992 let completed = proto::SearchEvent {
993 event: Some(proto::search_event::Event::Completed(
994 proto::SearchCompleted {
995 complete: true,
996 cancelled: false,
997 truncated: false,
998 warning: None,
999 },
1000 )),
1001 };
1002 assert!(matches!(
1003 SearchEvent::try_from(completed).unwrap(),
1004 SearchEvent::Completed(_)
1005 ));
1006
1007 assert!(matches!(
1008 SearchEvent::try_from(proto::SearchEvent::default()),
1009 Err(Error::Protocol(_))
1010 ));
1011 let missing_node = proto::SearchEvent {
1012 event: Some(proto::search_event::Event::Match(
1013 proto::SearchMatch::default(),
1014 )),
1015 };
1016 assert!(matches!(
1017 SearchEvent::try_from(missing_node),
1018 Err(Error::Protocol(_))
1019 ));
1020 }
1021
1022 #[test]
1023 fn indexed_search_response_preserves_identity_and_status() {
1024 let response = proto::SearchIndexResponse {
1025 matches: vec![proto::IndexedSearchMatch {
1026 item_id: "FCS0201!204FI00510.PV".into(),
1027 display_name: "PV".into(),
1028 kind: proto::BrowseNodeKind::BranchAndItem as i32,
1029 breadcrumbs: vec!["FCS0201".into(), "204FI00510".into()],
1030 }],
1031 has_more: true,
1032 status: Some(proto::SearchIndexStatus {
1033 server: "Yokogawa.CSHIS_OPC.1".into(),
1034 state: proto::SearchIndexState::Refreshing as i32,
1035 configured: true,
1036 active_generation: 7,
1037 entry_count: 100_001,
1038 unique_item_count: 100_000,
1039 started_at: Some("start".into()),
1040 completed_at: Some("complete".into()),
1041 last_error: Some("prior error".into()),
1042 database_bytes: 4096,
1043 organization: proto::NamespaceOrganization::Hierarchical as i32,
1044 source: proto::BrowseSource::Da2 as i32,
1045 progress: Some(proto::IndexedSearchProgress {
1046 branches_visited: 10,
1047 entries_seen: 20,
1048 unique_items: 19,
1049 active_time_ms: 30,
1050 paused_time_ms: 40,
1051 items_per_second: 12.5,
1052 estimated_remaining_ms: Some(50),
1053 }),
1054 }),
1055 };
1056 let typed = SearchIndexResponse::try_from(response).unwrap();
1057 assert_eq!(typed.matches[0].item_id, "FCS0201!204FI00510.PV");
1058 assert_eq!(typed.matches[0].kind, BrowseNodeKind::BranchAndItem);
1059 assert_eq!(typed.status.state, SearchIndexState::Refreshing);
1060 assert_eq!(
1061 typed
1062 .status
1063 .progress
1064 .as_ref()
1065 .unwrap()
1066 .estimated_remaining_ms,
1067 Some(50)
1068 );
1069 assert!(typed.has_more);
1070
1071 let item = IndexedSearchMatch::try_from(proto::IndexedSearchMatch {
1072 kind: proto::BrowseNodeKind::Item as i32,
1073 item_id: "id".into(),
1074 ..Default::default()
1075 })
1076 .unwrap();
1077 assert_eq!(item.kind, BrowseNodeKind::Item);
1078 assert!(matches!(
1079 IndexedSearchMatch::try_from(proto::IndexedSearchMatch {
1080 kind: proto::BrowseNodeKind::Item as i32,
1081 ..Default::default()
1082 }),
1083 Err(Error::Protocol(_))
1084 ));
1085
1086 assert!(matches!(
1087 IndexedSearchMatch::try_from(proto::IndexedSearchMatch {
1088 kind: proto::BrowseNodeKind::Branch as i32,
1089 ..Default::default()
1090 }),
1091 Err(Error::Protocol(_))
1092 ));
1093 assert!(matches!(
1094 SearchIndexResponse::try_from(proto::SearchIndexResponse::default()),
1095 Err(Error::Protocol(_))
1096 ));
1097 }
1098}