use crate::{Error, Result};
use opcda_bridge_proto::bridge as proto;
use std::fmt;
pub const DEFAULT_PAGE_SIZE: u32 = 200;
pub const DEFAULT_SEARCH_MAX_RESULTS: u32 = 200;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NamespaceOrganization {
Unspecified,
Flat,
Hierarchical,
}
impl fmt::Display for NamespaceOrganization {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Unspecified => "unspecified",
Self::Flat => "flat",
Self::Hierarchical => "hierarchical",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BrowseSource {
Unspecified,
Da3,
Da2,
Flat,
Derived,
}
impl fmt::Display for BrowseSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Unspecified => "unspecified",
Self::Da3 => "da3",
Self::Da2 => "da2",
Self::Flat => "flat",
Self::Derived => "derived",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BrowseNodeKind {
Unspecified,
Branch,
Item,
BranchAndItem,
}
impl BrowseNodeKind {
pub fn is_branch(self) -> bool {
matches!(self, Self::Branch | Self::BranchAndItem)
}
pub fn is_item(self) -> bool {
matches!(self, Self::Item | Self::BranchAndItem)
}
}
impl fmt::Display for BrowseNodeKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Unspecified => "unspecified",
Self::Branch => "branch",
Self::Item => "item",
Self::BranchAndItem => "branch-and-item",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchMatchMode {
Exact,
Prefix,
Contains,
}
impl fmt::Display for SearchMatchMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Exact => "exact",
Self::Prefix => "prefix",
Self::Contains => "contains",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Capabilities {
pub application_version: String,
pub protocol_version: String,
pub max_page_size: u32,
pub supports_browse_sessions: bool,
pub supports_search: bool,
pub organization: NamespaceOrganization,
pub source: BrowseSource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BrowseNode {
pub node_key: String,
pub display_name: String,
pub kind: BrowseNodeKind,
pub item_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BrowsePage {
pub session_id: String,
pub nodes: Vec<BrowseNode>,
pub next_page_token: Option<String>,
pub complete: bool,
pub organization: NamespaceOrganization,
pub source: BrowseSource,
pub warning: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BrowsePageRequest {
pub server: String,
pub session_id: Option<String>,
pub parent_node_key: Option<String>,
pub page_token: Option<String>,
pub page_size: u32,
pub refresh: bool,
}
impl BrowsePageRequest {
pub fn root(server: impl Into<String>, page_size: u32) -> Self {
Self {
server: server.into(),
session_id: None,
parent_node_key: None,
page_token: None,
page_size,
refresh: false,
}
}
pub fn children(
server: impl Into<String>,
session_id: impl Into<String>,
parent_node_key: impl Into<String>,
page_size: u32,
) -> Self {
Self {
server: server.into(),
session_id: Some(session_id.into()),
parent_node_key: Some(parent_node_key.into()),
page_token: None,
page_size,
refresh: false,
}
}
pub fn next(
server: impl Into<String>,
session_id: impl Into<String>,
parent_node_key: Option<String>,
page_token: impl Into<String>,
page_size: u32,
) -> Self {
Self {
server: server.into(),
session_id: Some(session_id.into()),
parent_node_key,
page_token: Some(page_token.into()),
page_size,
refresh: false,
}
}
pub fn with_refresh(mut self, refresh: bool) -> Self {
self.refresh = refresh;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchRequest {
pub server: String,
pub query: String,
pub match_mode: SearchMatchMode,
pub session_id: Option<String>,
pub scope_node_key: Option<String>,
pub max_results: u32,
pub include_branches: bool,
pub refresh: bool,
}
impl SearchRequest {
pub fn new(
server: impl Into<String>,
query: impl Into<String>,
match_mode: SearchMatchMode,
) -> Self {
Self {
server: server.into(),
query: query.into(),
match_mode,
session_id: None,
scope_node_key: None,
max_results: DEFAULT_SEARCH_MAX_RESULTS,
include_branches: false,
refresh: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BrowseBreadcrumb {
pub node_key: String,
pub display_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchMatch {
pub node: BrowseNode,
pub breadcrumbs: Vec<BrowseBreadcrumb>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchProgress {
pub visited_nodes: u32,
pub matches: u32,
pub partial: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchCompleted {
pub complete: bool,
pub cancelled: bool,
pub truncated: bool,
pub warning: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SearchEvent {
Match(SearchMatch),
Progress(SearchProgress),
Completed(SearchCompleted),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TagValue {
pub tag_id: String,
pub value: String,
pub quality: String,
pub timestamp: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WriteResult {
pub tag_id: String,
pub success: bool,
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Value {
String(String),
Int(i32),
Float(f64),
Bool(bool),
}
pub fn parse_value(raw: &str) -> Value {
if let Ok(b) = raw.parse::<bool>() {
return Value::Bool(b);
}
if let Ok(i) = raw.parse::<i32>() {
return Value::Int(i);
}
if let Ok(f) = raw.parse::<f64>() {
return Value::Float(f);
}
Value::String(raw.to_string())
}
fn invalid_enum(field: &str, value: i32) -> Error {
Error::Protocol(format!("gateway returned unknown {field} value {value}"))
}
fn organization(value: i32) -> Result<NamespaceOrganization> {
match proto::NamespaceOrganization::try_from(value)
.map_err(|_| invalid_enum("namespace organization", value))?
{
proto::NamespaceOrganization::Unspecified => Ok(NamespaceOrganization::Unspecified),
proto::NamespaceOrganization::Flat => Ok(NamespaceOrganization::Flat),
proto::NamespaceOrganization::Hierarchical => Ok(NamespaceOrganization::Hierarchical),
}
}
fn source(value: i32) -> Result<BrowseSource> {
match proto::BrowseSource::try_from(value).map_err(|_| invalid_enum("browse source", value))? {
proto::BrowseSource::Unspecified => Ok(BrowseSource::Unspecified),
proto::BrowseSource::Da3 => Ok(BrowseSource::Da3),
proto::BrowseSource::Da2 => Ok(BrowseSource::Da2),
proto::BrowseSource::Flat => Ok(BrowseSource::Flat),
proto::BrowseSource::Derived => Ok(BrowseSource::Derived),
}
}
fn node_kind(value: i32) -> Result<BrowseNodeKind> {
match proto::BrowseNodeKind::try_from(value)
.map_err(|_| invalid_enum("browse node kind", value))?
{
proto::BrowseNodeKind::Unspecified => Ok(BrowseNodeKind::Unspecified),
proto::BrowseNodeKind::Branch => Ok(BrowseNodeKind::Branch),
proto::BrowseNodeKind::Item => Ok(BrowseNodeKind::Item),
proto::BrowseNodeKind::BranchAndItem => Ok(BrowseNodeKind::BranchAndItem),
}
}
impl TryFrom<proto::GetCapabilitiesResponse> for Capabilities {
type Error = Error;
fn try_from(value: proto::GetCapabilitiesResponse) -> Result<Self> {
Ok(Self {
application_version: value.application_version,
protocol_version: value.protocol_version,
max_page_size: value.max_page_size,
supports_browse_sessions: value.supports_browse_sessions,
supports_search: value.supports_search,
organization: organization(value.organization)?,
source: source(value.source)?,
})
}
}
impl TryFrom<proto::BrowseNode> for BrowseNode {
type Error = Error;
fn try_from(value: proto::BrowseNode) -> Result<Self> {
let kind = node_kind(value.kind)?;
if kind.is_item() && value.item_id.is_none() {
return Err(Error::Protocol(
"gateway returned a selectable browse node without an ItemID".into(),
));
}
if !kind.is_item() && value.item_id.is_some() {
return Err(Error::Protocol(
"gateway returned an ItemID for a non-selectable browse node".into(),
));
}
Ok(Self {
node_key: value.node_key,
display_name: value.display_name,
kind,
item_id: value.item_id,
})
}
}
impl TryFrom<proto::BrowsePage> for BrowsePage {
type Error = Error;
fn try_from(value: proto::BrowsePage) -> Result<Self> {
if value.complete && value.next_page_token.is_some() {
return Err(Error::Protocol(
"gateway returned a complete browse page with a continuation token".into(),
));
}
if !value.complete && value.next_page_token.is_none() {
return Err(Error::Protocol(
"gateway returned an incomplete browse page without a continuation token".into(),
));
}
Ok(Self {
session_id: value.session_id,
nodes: value
.nodes
.into_iter()
.map(BrowseNode::try_from)
.collect::<Result<_>>()?,
next_page_token: value.next_page_token,
complete: value.complete,
organization: organization(value.organization)?,
source: source(value.source)?,
warning: value.warning,
})
}
}
impl From<BrowsePageRequest> for proto::BrowseRequest {
fn from(value: BrowsePageRequest) -> Self {
Self {
server: value.server,
session_id: value.session_id,
parent_node_key: value.parent_node_key,
page_token: value.page_token,
page_size: value.page_size,
refresh: value.refresh,
}
}
}
impl From<SearchRequest> for proto::SearchRequest {
fn from(value: SearchRequest) -> Self {
let match_mode = match value.match_mode {
SearchMatchMode::Exact => proto::SearchMatchMode::Exact,
SearchMatchMode::Prefix => proto::SearchMatchMode::Prefix,
SearchMatchMode::Contains => proto::SearchMatchMode::Contains,
};
Self {
server: value.server,
query: value.query,
match_mode: match_mode as i32,
session_id: value.session_id,
scope_node_key: value.scope_node_key,
max_results: value.max_results,
include_branches: value.include_branches,
refresh: value.refresh,
}
}
}
impl TryFrom<proto::SearchEvent> for SearchEvent {
type Error = Error;
fn try_from(value: proto::SearchEvent) -> Result<Self> {
match value.event {
Some(proto::search_event::Event::Match(found)) => {
let node = found.node.ok_or_else(|| {
Error::Protocol("gateway returned a search match without a node".into())
})?;
Ok(Self::Match(SearchMatch {
node: node.try_into()?,
breadcrumbs: found
.breadcrumbs
.into_iter()
.map(|part| BrowseBreadcrumb {
node_key: part.node_key,
display_name: part.display_name,
})
.collect(),
}))
}
Some(proto::search_event::Event::Progress(progress)) => {
Ok(Self::Progress(SearchProgress {
visited_nodes: progress.visited_nodes,
matches: progress.matches,
partial: progress.partial,
}))
}
Some(proto::search_event::Event::Completed(completed)) => {
Ok(Self::Completed(SearchCompleted {
complete: completed.complete,
cancelled: completed.cancelled,
truncated: completed.truncated,
warning: completed.warning,
}))
}
None => Err(Error::Protocol(
"gateway returned an empty search event".into(),
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn value_parsing_covers_all_variants() {
assert!(matches!(parse_value("true"), Value::Bool(true)));
assert!(matches!(parse_value("false"), Value::Bool(false)));
assert!(matches!(parse_value("42"), Value::Int(42)));
assert!(matches!(parse_value("-1"), Value::Int(-1)));
assert!(matches!(parse_value("9.5"), Value::Float(v) if v == 9.5));
assert!(matches!(parse_value("hello"), Value::String(v) if v == "hello"));
}
#[test]
fn enum_display_and_node_predicates_are_stable() {
assert_eq!(
NamespaceOrganization::Unspecified.to_string(),
"unspecified"
);
assert_eq!(NamespaceOrganization::Flat.to_string(), "flat");
assert_eq!(
NamespaceOrganization::Hierarchical.to_string(),
"hierarchical"
);
assert_eq!(BrowseSource::Unspecified.to_string(), "unspecified");
assert_eq!(BrowseSource::Da3.to_string(), "da3");
assert_eq!(BrowseSource::Da2.to_string(), "da2");
assert_eq!(BrowseSource::Flat.to_string(), "flat");
assert_eq!(BrowseSource::Derived.to_string(), "derived");
assert_eq!(BrowseNodeKind::Unspecified.to_string(), "unspecified");
assert_eq!(BrowseNodeKind::Branch.to_string(), "branch");
assert_eq!(BrowseNodeKind::Item.to_string(), "item");
assert_eq!(BrowseNodeKind::BranchAndItem.to_string(), "branch-and-item");
assert_eq!(SearchMatchMode::Exact.to_string(), "exact");
assert_eq!(SearchMatchMode::Prefix.to_string(), "prefix");
assert_eq!(SearchMatchMode::Contains.to_string(), "contains");
assert!(BrowseNodeKind::Branch.is_branch());
assert!(!BrowseNodeKind::Branch.is_item());
assert!(BrowseNodeKind::Item.is_item());
assert!(!BrowseNodeKind::Item.is_branch());
assert!(BrowseNodeKind::BranchAndItem.is_branch());
assert!(BrowseNodeKind::BranchAndItem.is_item());
assert!(!BrowseNodeKind::Unspecified.is_branch());
assert!(!BrowseNodeKind::Unspecified.is_item());
}
#[test]
fn browse_request_builders_map_all_fields() {
let root = BrowsePageRequest::root("S", 20).with_refresh(true);
assert_eq!(root.server, "S");
assert_eq!(root.page_size, 20);
assert!(root.refresh);
let children = BrowsePageRequest::children("S", "session", "node", 30);
assert_eq!(children.session_id.as_deref(), Some("session"));
assert_eq!(children.parent_node_key.as_deref(), Some("node"));
let next = BrowsePageRequest::next("S", "session", Some("node".into()), "token", 40);
let proto: proto::BrowseRequest = next.into();
assert_eq!(proto.page_token.as_deref(), Some("token"));
assert_eq!(proto.page_size, 40);
}
#[test]
fn search_request_defaults_and_mapping_are_typed() {
for (mode, expected) in [
(SearchMatchMode::Exact, proto::SearchMatchMode::Exact),
(SearchMatchMode::Prefix, proto::SearchMatchMode::Prefix),
(SearchMatchMode::Contains, proto::SearchMatchMode::Contains),
] {
let request = SearchRequest::new("S", "query", mode);
assert_eq!(request.max_results, DEFAULT_SEARCH_MAX_RESULTS);
let mapped: proto::SearchRequest = request.into();
assert_eq!(mapped.match_mode, expected as i32);
}
}
#[test]
fn invalid_and_inconsistent_proto_values_are_rejected() {
assert_eq!(
organization(proto::NamespaceOrganization::Unspecified as i32).unwrap(),
NamespaceOrganization::Unspecified
);
assert_eq!(
organization(proto::NamespaceOrganization::Flat as i32).unwrap(),
NamespaceOrganization::Flat
);
assert_eq!(
organization(proto::NamespaceOrganization::Hierarchical as i32).unwrap(),
NamespaceOrganization::Hierarchical
);
assert_eq!(
source(proto::BrowseSource::Unspecified as i32).unwrap(),
BrowseSource::Unspecified
);
assert_eq!(
source(proto::BrowseSource::Da3 as i32).unwrap(),
BrowseSource::Da3
);
assert_eq!(
source(proto::BrowseSource::Da2 as i32).unwrap(),
BrowseSource::Da2
);
assert_eq!(
source(proto::BrowseSource::Flat as i32).unwrap(),
BrowseSource::Flat
);
assert_eq!(
source(proto::BrowseSource::Derived as i32).unwrap(),
BrowseSource::Derived
);
assert_eq!(
node_kind(proto::BrowseNodeKind::Unspecified as i32).unwrap(),
BrowseNodeKind::Unspecified
);
assert_eq!(
node_kind(proto::BrowseNodeKind::Branch as i32).unwrap(),
BrowseNodeKind::Branch
);
assert_eq!(
node_kind(proto::BrowseNodeKind::Item as i32).unwrap(),
BrowseNodeKind::Item
);
assert_eq!(
node_kind(proto::BrowseNodeKind::BranchAndItem as i32).unwrap(),
BrowseNodeKind::BranchAndItem
);
assert!(matches!(organization(99), Err(Error::Protocol(_))));
assert!(matches!(source(99), Err(Error::Protocol(_))));
assert!(matches!(node_kind(99), Err(Error::Protocol(_))));
let missing_item_id = proto::BrowseNode {
kind: proto::BrowseNodeKind::Item as i32,
..Default::default()
};
assert!(matches!(
BrowseNode::try_from(missing_item_id),
Err(Error::Protocol(_))
));
let unexpected_item_id = proto::BrowseNode {
kind: proto::BrowseNodeKind::Branch as i32,
item_id: Some("not-valid".into()),
..Default::default()
};
assert!(matches!(
BrowseNode::try_from(unexpected_item_id),
Err(Error::Protocol(_))
));
let complete_with_token = proto::BrowsePage {
complete: true,
next_page_token: Some("token".into()),
..Default::default()
};
assert!(matches!(
BrowsePage::try_from(complete_with_token),
Err(Error::Protocol(_))
));
let incomplete_without_token = proto::BrowsePage::default();
assert!(matches!(
BrowsePage::try_from(incomplete_without_token),
Err(Error::Protocol(_))
));
}
#[test]
fn search_event_conversion_covers_every_event() {
let found = proto::SearchEvent {
event: Some(proto::search_event::Event::Match(proto::SearchMatch {
node: Some(proto::BrowseNode {
node_key: "n".into(),
display_name: "PV".into(),
kind: proto::BrowseNodeKind::Item as i32,
item_id: Some("FCS!TAG.PV".into()),
}),
breadcrumbs: vec![proto::BrowseBreadcrumb {
node_key: "root".into(),
display_name: "FCS".into(),
}],
})),
};
assert!(matches!(
SearchEvent::try_from(found).unwrap(),
SearchEvent::Match(_)
));
let progress = proto::SearchEvent {
event: Some(proto::search_event::Event::Progress(
proto::SearchProgress {
visited_nodes: 10,
matches: 2,
partial: true,
},
)),
};
assert!(matches!(
SearchEvent::try_from(progress).unwrap(),
SearchEvent::Progress(_)
));
let completed = proto::SearchEvent {
event: Some(proto::search_event::Event::Completed(
proto::SearchCompleted {
complete: true,
cancelled: false,
truncated: false,
warning: None,
},
)),
};
assert!(matches!(
SearchEvent::try_from(completed).unwrap(),
SearchEvent::Completed(_)
));
assert!(matches!(
SearchEvent::try_from(proto::SearchEvent::default()),
Err(Error::Protocol(_))
));
let missing_node = proto::SearchEvent {
event: Some(proto::search_event::Event::Match(
proto::SearchMatch::default(),
)),
};
assert!(matches!(
SearchEvent::try_from(missing_node),
Err(Error::Protocol(_))
));
}
}