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;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum NamespaceOrganization {
15 Unspecified,
16 Flat,
17 Hierarchical,
18}
19
20impl fmt::Display for NamespaceOrganization {
21 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22 f.write_str(match self {
23 Self::Unspecified => "unspecified",
24 Self::Flat => "flat",
25 Self::Hierarchical => "hierarchical",
26 })
27 }
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum BrowseSource {
33 Unspecified,
34 Da3,
35 Da2,
36 Flat,
37 Derived,
38}
39
40impl fmt::Display for BrowseSource {
41 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42 f.write_str(match self {
43 Self::Unspecified => "unspecified",
44 Self::Da3 => "da3",
45 Self::Da2 => "da2",
46 Self::Flat => "flat",
47 Self::Derived => "derived",
48 })
49 }
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum BrowseNodeKind {
55 Unspecified,
56 Branch,
57 Item,
58 BranchAndItem,
59}
60
61impl BrowseNodeKind {
62 pub fn is_branch(self) -> bool {
64 matches!(self, Self::Branch | Self::BranchAndItem)
65 }
66
67 pub fn is_item(self) -> bool {
69 matches!(self, Self::Item | Self::BranchAndItem)
70 }
71}
72
73impl fmt::Display for BrowseNodeKind {
74 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75 f.write_str(match self {
76 Self::Unspecified => "unspecified",
77 Self::Branch => "branch",
78 Self::Item => "item",
79 Self::BranchAndItem => "branch-and-item",
80 })
81 }
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum SearchMatchMode {
87 Exact,
88 Prefix,
89 Contains,
90}
91
92impl fmt::Display for SearchMatchMode {
93 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94 f.write_str(match self {
95 Self::Exact => "exact",
96 Self::Prefix => "prefix",
97 Self::Contains => "contains",
98 })
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct Capabilities {
105 pub application_version: String,
106 pub protocol_version: String,
107 pub max_page_size: u32,
108 pub supports_browse_sessions: bool,
109 pub supports_search: bool,
110 pub organization: NamespaceOrganization,
111 pub source: BrowseSource,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct BrowseNode {
117 pub node_key: String,
119 pub display_name: String,
121 pub kind: BrowseNodeKind,
122 pub item_id: Option<String>,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct BrowsePage {
129 pub session_id: String,
130 pub nodes: Vec<BrowseNode>,
131 pub next_page_token: Option<String>,
132 pub complete: bool,
133 pub organization: NamespaceOrganization,
134 pub source: BrowseSource,
135 pub warning: Option<String>,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct BrowsePageRequest {
141 pub server: String,
142 pub session_id: Option<String>,
143 pub parent_node_key: Option<String>,
144 pub page_token: Option<String>,
145 pub page_size: u32,
146 pub refresh: bool,
147}
148
149impl BrowsePageRequest {
150 pub fn root(server: impl Into<String>, page_size: u32) -> Self {
152 Self {
153 server: server.into(),
154 session_id: None,
155 parent_node_key: None,
156 page_token: None,
157 page_size,
158 refresh: false,
159 }
160 }
161
162 pub fn children(
164 server: impl Into<String>,
165 session_id: impl Into<String>,
166 parent_node_key: impl Into<String>,
167 page_size: u32,
168 ) -> Self {
169 Self {
170 server: server.into(),
171 session_id: Some(session_id.into()),
172 parent_node_key: Some(parent_node_key.into()),
173 page_token: None,
174 page_size,
175 refresh: false,
176 }
177 }
178
179 pub fn next(
181 server: impl Into<String>,
182 session_id: impl Into<String>,
183 parent_node_key: Option<String>,
184 page_token: impl Into<String>,
185 page_size: u32,
186 ) -> Self {
187 Self {
188 server: server.into(),
189 session_id: Some(session_id.into()),
190 parent_node_key,
191 page_token: Some(page_token.into()),
192 page_size,
193 refresh: false,
194 }
195 }
196
197 pub fn with_refresh(mut self, refresh: bool) -> Self {
199 self.refresh = refresh;
200 self
201 }
202}
203
204#[derive(Debug, Clone, PartialEq, Eq)]
206pub struct SearchRequest {
207 pub server: String,
208 pub query: String,
209 pub match_mode: SearchMatchMode,
210 pub session_id: Option<String>,
211 pub scope_node_key: Option<String>,
212 pub max_results: u32,
213 pub include_branches: bool,
214 pub refresh: bool,
215}
216
217impl SearchRequest {
218 pub fn new(
219 server: impl Into<String>,
220 query: impl Into<String>,
221 match_mode: SearchMatchMode,
222 ) -> Self {
223 Self {
224 server: server.into(),
225 query: query.into(),
226 match_mode,
227 session_id: None,
228 scope_node_key: None,
229 max_results: DEFAULT_SEARCH_MAX_RESULTS,
230 include_branches: false,
231 refresh: false,
232 }
233 }
234}
235
236#[derive(Debug, Clone, PartialEq, Eq)]
238pub struct BrowseBreadcrumb {
239 pub node_key: String,
240 pub display_name: String,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq)]
245pub struct SearchMatch {
246 pub node: BrowseNode,
247 pub breadcrumbs: Vec<BrowseBreadcrumb>,
248}
249
250#[derive(Debug, Clone, PartialEq, Eq)]
252pub struct SearchProgress {
253 pub visited_nodes: u32,
254 pub matches: u32,
255 pub partial: bool,
256}
257
258#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct SearchCompleted {
261 pub complete: bool,
262 pub cancelled: bool,
263 pub truncated: bool,
264 pub warning: Option<String>,
265}
266
267#[derive(Debug, Clone, PartialEq, Eq)]
269pub enum SearchEvent {
270 Match(SearchMatch),
271 Progress(SearchProgress),
272 Completed(SearchCompleted),
273}
274
275#[derive(Debug, Clone, PartialEq, Eq)]
277pub struct TagValue {
278 pub tag_id: String,
279 pub value: String,
280 pub quality: String,
281 pub timestamp: String,
282}
283
284#[derive(Debug, Clone, PartialEq, Eq)]
286pub struct WriteResult {
287 pub tag_id: String,
288 pub success: bool,
289 pub error: Option<String>,
290}
291
292#[derive(Debug, Clone, PartialEq)]
294pub enum Value {
295 String(String),
296 Int(i32),
297 Float(f64),
298 Bool(bool),
299}
300
301pub fn parse_value(raw: &str) -> Value {
303 if let Ok(b) = raw.parse::<bool>() {
304 return Value::Bool(b);
305 }
306 if let Ok(i) = raw.parse::<i32>() {
307 return Value::Int(i);
308 }
309 if let Ok(f) = raw.parse::<f64>() {
310 return Value::Float(f);
311 }
312 Value::String(raw.to_string())
313}
314
315fn invalid_enum(field: &str, value: i32) -> Error {
316 Error::Protocol(format!("gateway returned unknown {field} value {value}"))
317}
318
319fn organization(value: i32) -> Result<NamespaceOrganization> {
320 match proto::NamespaceOrganization::try_from(value)
321 .map_err(|_| invalid_enum("namespace organization", value))?
322 {
323 proto::NamespaceOrganization::Unspecified => Ok(NamespaceOrganization::Unspecified),
324 proto::NamespaceOrganization::Flat => Ok(NamespaceOrganization::Flat),
325 proto::NamespaceOrganization::Hierarchical => Ok(NamespaceOrganization::Hierarchical),
326 }
327}
328
329fn source(value: i32) -> Result<BrowseSource> {
330 match proto::BrowseSource::try_from(value).map_err(|_| invalid_enum("browse source", value))? {
331 proto::BrowseSource::Unspecified => Ok(BrowseSource::Unspecified),
332 proto::BrowseSource::Da3 => Ok(BrowseSource::Da3),
333 proto::BrowseSource::Da2 => Ok(BrowseSource::Da2),
334 proto::BrowseSource::Flat => Ok(BrowseSource::Flat),
335 proto::BrowseSource::Derived => Ok(BrowseSource::Derived),
336 }
337}
338
339fn node_kind(value: i32) -> Result<BrowseNodeKind> {
340 match proto::BrowseNodeKind::try_from(value)
341 .map_err(|_| invalid_enum("browse node kind", value))?
342 {
343 proto::BrowseNodeKind::Unspecified => Ok(BrowseNodeKind::Unspecified),
344 proto::BrowseNodeKind::Branch => Ok(BrowseNodeKind::Branch),
345 proto::BrowseNodeKind::Item => Ok(BrowseNodeKind::Item),
346 proto::BrowseNodeKind::BranchAndItem => Ok(BrowseNodeKind::BranchAndItem),
347 }
348}
349
350impl TryFrom<proto::GetCapabilitiesResponse> for Capabilities {
351 type Error = Error;
352
353 fn try_from(value: proto::GetCapabilitiesResponse) -> Result<Self> {
354 Ok(Self {
355 application_version: value.application_version,
356 protocol_version: value.protocol_version,
357 max_page_size: value.max_page_size,
358 supports_browse_sessions: value.supports_browse_sessions,
359 supports_search: value.supports_search,
360 organization: organization(value.organization)?,
361 source: source(value.source)?,
362 })
363 }
364}
365
366impl TryFrom<proto::BrowseNode> for BrowseNode {
367 type Error = Error;
368
369 fn try_from(value: proto::BrowseNode) -> Result<Self> {
370 let kind = node_kind(value.kind)?;
371 if kind.is_item() && value.item_id.is_none() {
372 return Err(Error::Protocol(
373 "gateway returned a selectable browse node without an ItemID".into(),
374 ));
375 }
376 if !kind.is_item() && value.item_id.is_some() {
377 return Err(Error::Protocol(
378 "gateway returned an ItemID for a non-selectable browse node".into(),
379 ));
380 }
381 Ok(Self {
382 node_key: value.node_key,
383 display_name: value.display_name,
384 kind,
385 item_id: value.item_id,
386 })
387 }
388}
389
390impl TryFrom<proto::BrowsePage> for BrowsePage {
391 type Error = Error;
392
393 fn try_from(value: proto::BrowsePage) -> Result<Self> {
394 if value.complete && value.next_page_token.is_some() {
395 return Err(Error::Protocol(
396 "gateway returned a complete browse page with a continuation token".into(),
397 ));
398 }
399 if !value.complete && value.next_page_token.is_none() {
400 return Err(Error::Protocol(
401 "gateway returned an incomplete browse page without a continuation token".into(),
402 ));
403 }
404 Ok(Self {
405 session_id: value.session_id,
406 nodes: value
407 .nodes
408 .into_iter()
409 .map(BrowseNode::try_from)
410 .collect::<Result<_>>()?,
411 next_page_token: value.next_page_token,
412 complete: value.complete,
413 organization: organization(value.organization)?,
414 source: source(value.source)?,
415 warning: value.warning,
416 })
417 }
418}
419
420impl From<BrowsePageRequest> for proto::BrowseRequest {
421 fn from(value: BrowsePageRequest) -> Self {
422 Self {
423 server: value.server,
424 session_id: value.session_id,
425 parent_node_key: value.parent_node_key,
426 page_token: value.page_token,
427 page_size: value.page_size,
428 refresh: value.refresh,
429 }
430 }
431}
432
433impl From<SearchRequest> for proto::SearchRequest {
434 fn from(value: SearchRequest) -> Self {
435 let match_mode = match value.match_mode {
436 SearchMatchMode::Exact => proto::SearchMatchMode::Exact,
437 SearchMatchMode::Prefix => proto::SearchMatchMode::Prefix,
438 SearchMatchMode::Contains => proto::SearchMatchMode::Contains,
439 };
440 Self {
441 server: value.server,
442 query: value.query,
443 match_mode: match_mode as i32,
444 session_id: value.session_id,
445 scope_node_key: value.scope_node_key,
446 max_results: value.max_results,
447 include_branches: value.include_branches,
448 refresh: value.refresh,
449 }
450 }
451}
452
453impl TryFrom<proto::SearchEvent> for SearchEvent {
454 type Error = Error;
455
456 fn try_from(value: proto::SearchEvent) -> Result<Self> {
457 match value.event {
458 Some(proto::search_event::Event::Match(found)) => {
459 let node = found.node.ok_or_else(|| {
460 Error::Protocol("gateway returned a search match without a node".into())
461 })?;
462 Ok(Self::Match(SearchMatch {
463 node: node.try_into()?,
464 breadcrumbs: found
465 .breadcrumbs
466 .into_iter()
467 .map(|part| BrowseBreadcrumb {
468 node_key: part.node_key,
469 display_name: part.display_name,
470 })
471 .collect(),
472 }))
473 }
474 Some(proto::search_event::Event::Progress(progress)) => {
475 Ok(Self::Progress(SearchProgress {
476 visited_nodes: progress.visited_nodes,
477 matches: progress.matches,
478 partial: progress.partial,
479 }))
480 }
481 Some(proto::search_event::Event::Completed(completed)) => {
482 Ok(Self::Completed(SearchCompleted {
483 complete: completed.complete,
484 cancelled: completed.cancelled,
485 truncated: completed.truncated,
486 warning: completed.warning,
487 }))
488 }
489 None => Err(Error::Protocol(
490 "gateway returned an empty search event".into(),
491 )),
492 }
493 }
494}
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499
500 #[test]
501 fn value_parsing_covers_all_variants() {
502 assert!(matches!(parse_value("true"), Value::Bool(true)));
503 assert!(matches!(parse_value("false"), Value::Bool(false)));
504 assert!(matches!(parse_value("42"), Value::Int(42)));
505 assert!(matches!(parse_value("-1"), Value::Int(-1)));
506 assert!(matches!(parse_value("9.5"), Value::Float(v) if v == 9.5));
507 assert!(matches!(parse_value("hello"), Value::String(v) if v == "hello"));
508 }
509
510 #[test]
511 fn enum_display_and_node_predicates_are_stable() {
512 assert_eq!(
513 NamespaceOrganization::Unspecified.to_string(),
514 "unspecified"
515 );
516 assert_eq!(NamespaceOrganization::Flat.to_string(), "flat");
517 assert_eq!(
518 NamespaceOrganization::Hierarchical.to_string(),
519 "hierarchical"
520 );
521 assert_eq!(BrowseSource::Unspecified.to_string(), "unspecified");
522 assert_eq!(BrowseSource::Da3.to_string(), "da3");
523 assert_eq!(BrowseSource::Da2.to_string(), "da2");
524 assert_eq!(BrowseSource::Flat.to_string(), "flat");
525 assert_eq!(BrowseSource::Derived.to_string(), "derived");
526 assert_eq!(BrowseNodeKind::Unspecified.to_string(), "unspecified");
527 assert_eq!(BrowseNodeKind::Branch.to_string(), "branch");
528 assert_eq!(BrowseNodeKind::Item.to_string(), "item");
529 assert_eq!(BrowseNodeKind::BranchAndItem.to_string(), "branch-and-item");
530 assert_eq!(SearchMatchMode::Exact.to_string(), "exact");
531 assert_eq!(SearchMatchMode::Prefix.to_string(), "prefix");
532 assert_eq!(SearchMatchMode::Contains.to_string(), "contains");
533 assert!(BrowseNodeKind::Branch.is_branch());
534 assert!(!BrowseNodeKind::Branch.is_item());
535 assert!(BrowseNodeKind::Item.is_item());
536 assert!(!BrowseNodeKind::Item.is_branch());
537 assert!(BrowseNodeKind::BranchAndItem.is_branch());
538 assert!(BrowseNodeKind::BranchAndItem.is_item());
539 assert!(!BrowseNodeKind::Unspecified.is_branch());
540 assert!(!BrowseNodeKind::Unspecified.is_item());
541 }
542
543 #[test]
544 fn browse_request_builders_map_all_fields() {
545 let root = BrowsePageRequest::root("S", 20).with_refresh(true);
546 assert_eq!(root.server, "S");
547 assert_eq!(root.page_size, 20);
548 assert!(root.refresh);
549
550 let children = BrowsePageRequest::children("S", "session", "node", 30);
551 assert_eq!(children.session_id.as_deref(), Some("session"));
552 assert_eq!(children.parent_node_key.as_deref(), Some("node"));
553
554 let next = BrowsePageRequest::next("S", "session", Some("node".into()), "token", 40);
555 let proto: proto::BrowseRequest = next.into();
556 assert_eq!(proto.page_token.as_deref(), Some("token"));
557 assert_eq!(proto.page_size, 40);
558 }
559
560 #[test]
561 fn search_request_defaults_and_mapping_are_typed() {
562 for (mode, expected) in [
563 (SearchMatchMode::Exact, proto::SearchMatchMode::Exact),
564 (SearchMatchMode::Prefix, proto::SearchMatchMode::Prefix),
565 (SearchMatchMode::Contains, proto::SearchMatchMode::Contains),
566 ] {
567 let request = SearchRequest::new("S", "query", mode);
568 assert_eq!(request.max_results, DEFAULT_SEARCH_MAX_RESULTS);
569 let mapped: proto::SearchRequest = request.into();
570 assert_eq!(mapped.match_mode, expected as i32);
571 }
572 }
573
574 #[test]
575 fn invalid_and_inconsistent_proto_values_are_rejected() {
576 assert_eq!(
577 organization(proto::NamespaceOrganization::Unspecified as i32).unwrap(),
578 NamespaceOrganization::Unspecified
579 );
580 assert_eq!(
581 organization(proto::NamespaceOrganization::Flat as i32).unwrap(),
582 NamespaceOrganization::Flat
583 );
584 assert_eq!(
585 organization(proto::NamespaceOrganization::Hierarchical as i32).unwrap(),
586 NamespaceOrganization::Hierarchical
587 );
588 assert_eq!(
589 source(proto::BrowseSource::Unspecified as i32).unwrap(),
590 BrowseSource::Unspecified
591 );
592 assert_eq!(
593 source(proto::BrowseSource::Da3 as i32).unwrap(),
594 BrowseSource::Da3
595 );
596 assert_eq!(
597 source(proto::BrowseSource::Da2 as i32).unwrap(),
598 BrowseSource::Da2
599 );
600 assert_eq!(
601 source(proto::BrowseSource::Flat as i32).unwrap(),
602 BrowseSource::Flat
603 );
604 assert_eq!(
605 source(proto::BrowseSource::Derived as i32).unwrap(),
606 BrowseSource::Derived
607 );
608 assert_eq!(
609 node_kind(proto::BrowseNodeKind::Unspecified as i32).unwrap(),
610 BrowseNodeKind::Unspecified
611 );
612 assert_eq!(
613 node_kind(proto::BrowseNodeKind::Branch as i32).unwrap(),
614 BrowseNodeKind::Branch
615 );
616 assert_eq!(
617 node_kind(proto::BrowseNodeKind::Item as i32).unwrap(),
618 BrowseNodeKind::Item
619 );
620 assert_eq!(
621 node_kind(proto::BrowseNodeKind::BranchAndItem as i32).unwrap(),
622 BrowseNodeKind::BranchAndItem
623 );
624 assert!(matches!(organization(99), Err(Error::Protocol(_))));
625 assert!(matches!(source(99), Err(Error::Protocol(_))));
626 assert!(matches!(node_kind(99), Err(Error::Protocol(_))));
627
628 let missing_item_id = proto::BrowseNode {
629 kind: proto::BrowseNodeKind::Item as i32,
630 ..Default::default()
631 };
632 assert!(matches!(
633 BrowseNode::try_from(missing_item_id),
634 Err(Error::Protocol(_))
635 ));
636 let unexpected_item_id = proto::BrowseNode {
637 kind: proto::BrowseNodeKind::Branch as i32,
638 item_id: Some("not-valid".into()),
639 ..Default::default()
640 };
641 assert!(matches!(
642 BrowseNode::try_from(unexpected_item_id),
643 Err(Error::Protocol(_))
644 ));
645
646 let complete_with_token = proto::BrowsePage {
647 complete: true,
648 next_page_token: Some("token".into()),
649 ..Default::default()
650 };
651 assert!(matches!(
652 BrowsePage::try_from(complete_with_token),
653 Err(Error::Protocol(_))
654 ));
655
656 let incomplete_without_token = proto::BrowsePage::default();
657 assert!(matches!(
658 BrowsePage::try_from(incomplete_without_token),
659 Err(Error::Protocol(_))
660 ));
661 }
662
663 #[test]
664 fn search_event_conversion_covers_every_event() {
665 let found = proto::SearchEvent {
666 event: Some(proto::search_event::Event::Match(proto::SearchMatch {
667 node: Some(proto::BrowseNode {
668 node_key: "n".into(),
669 display_name: "PV".into(),
670 kind: proto::BrowseNodeKind::Item as i32,
671 item_id: Some("FCS!TAG.PV".into()),
672 }),
673 breadcrumbs: vec![proto::BrowseBreadcrumb {
674 node_key: "root".into(),
675 display_name: "FCS".into(),
676 }],
677 })),
678 };
679 assert!(matches!(
680 SearchEvent::try_from(found).unwrap(),
681 SearchEvent::Match(_)
682 ));
683
684 let progress = proto::SearchEvent {
685 event: Some(proto::search_event::Event::Progress(
686 proto::SearchProgress {
687 visited_nodes: 10,
688 matches: 2,
689 partial: true,
690 },
691 )),
692 };
693 assert!(matches!(
694 SearchEvent::try_from(progress).unwrap(),
695 SearchEvent::Progress(_)
696 ));
697
698 let completed = proto::SearchEvent {
699 event: Some(proto::search_event::Event::Completed(
700 proto::SearchCompleted {
701 complete: true,
702 cancelled: false,
703 truncated: false,
704 warning: None,
705 },
706 )),
707 };
708 assert!(matches!(
709 SearchEvent::try_from(completed).unwrap(),
710 SearchEvent::Completed(_)
711 ));
712
713 assert!(matches!(
714 SearchEvent::try_from(proto::SearchEvent::default()),
715 Err(Error::Protocol(_))
716 ));
717 let missing_node = proto::SearchEvent {
718 event: Some(proto::search_event::Event::Match(
719 proto::SearchMatch::default(),
720 )),
721 };
722 assert!(matches!(
723 SearchEvent::try_from(missing_node),
724 Err(Error::Protocol(_))
725 ));
726 }
727}