use alloc::string::String;
use alloc::vec::Vec;
pub const DEFAULT_PACKAGE: &str = "zerodds.bridge.v1";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TopicService {
pub package: String,
pub service: String,
pub dds_topic: String,
pub dds_type: String,
}
impl TopicService {
#[must_use]
pub fn from_topic(dds_topic: &str, dds_type: &str) -> Self {
Self {
package: DEFAULT_PACKAGE.into(),
service: format!("{}Stream", topic_to_pascal(dds_topic)),
dds_topic: dds_topic.into(),
dds_type: dds_type.into(),
}
}
#[must_use]
pub fn publish_path(&self) -> String {
format!("/{}.{}/Publish", self.package, self.service)
}
#[must_use]
pub fn subscribe_path(&self) -> String {
format!("/{}.{}/Subscribe", self.package, self.service)
}
#[must_use]
pub fn matches_path(&self, path: &str) -> Option<MethodKind> {
if path == self.publish_path() {
Some(MethodKind::Publish)
} else if path == self.subscribe_path() {
Some(MethodKind::Subscribe)
} else {
None
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MethodKind {
Publish,
Subscribe,
}
#[must_use]
pub fn topic_to_pascal(topic: &str) -> String {
let mut out = String::new();
let mut up = true;
for c in topic.chars() {
if c == '.' || c == '/' || c == '-' || c == '_' || c == ' ' {
up = true;
continue;
}
if up {
for u in c.to_uppercase() {
out.push(u);
}
up = false;
} else {
out.push(c);
}
}
if out.is_empty() {
out.push_str("Topic");
}
out
}
#[must_use]
pub fn render_proto(svc: &TopicService) -> String {
let mut out = String::new();
out.push_str("syntax = \"proto3\";\n");
out.push_str(&format!("package {};\n\n", svc.package));
out.push_str("message Sample { bytes payload = 1; }\n");
out.push_str("message PublishAck { uint64 accepted = 1; }\n");
out.push_str("message SubscribeReq { string filter = 1; }\n\n");
out.push_str(&format!("service {} {{\n", svc.service));
out.push_str(" rpc Publish(stream Sample) returns (PublishAck);\n");
out.push_str(" rpc Subscribe(SubscribeReq) returns (stream Sample);\n");
out.push_str("}\n");
out
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ServiceCatalog {
services: Vec<TopicService>,
}
impl ServiceCatalog {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, svc: TopicService) {
if !self.services.iter().any(|s| s.service == svc.service) {
self.services.push(svc);
}
}
pub fn iter(&self) -> impl Iterator<Item = &TopicService> {
self.services.iter()
}
#[must_use]
pub fn len(&self) -> usize {
self.services.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.services.is_empty()
}
#[must_use]
pub fn find_by_service(&self, service: &str) -> Option<&TopicService> {
self.services.iter().find(|s| s.service == service)
}
#[must_use]
pub fn find_by_path(&self, path: &str) -> Option<(&TopicService, MethodKind)> {
for s in &self.services {
if let Some(k) = s.matches_path(path) {
return Some((s, k));
}
}
None
}
#[must_use]
pub fn fully_qualified_service_names(&self) -> Vec<String> {
self.services
.iter()
.map(|s| format!("{}.{}", s.package, s.service))
.collect()
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn pascal_simple() {
assert_eq!(topic_to_pascal("Trade"), "Trade");
}
#[test]
fn pascal_dotted() {
assert_eq!(topic_to_pascal("dds.demo.Trade"), "DdsDemoTrade");
}
#[test]
fn pascal_mixed_separators() {
assert_eq!(topic_to_pascal("chat-room/foo_bar"), "ChatRoomFooBar");
}
#[test]
fn pascal_empty_falls_back() {
assert_eq!(topic_to_pascal(""), "Topic");
}
#[test]
fn topic_service_paths() {
let s = TopicService::from_topic("Trade", "Trade");
assert_eq!(s.publish_path(), "/zerodds.bridge.v1.TradeStream/Publish");
assert_eq!(
s.subscribe_path(),
"/zerodds.bridge.v1.TradeStream/Subscribe"
);
}
#[test]
fn matches_path_publish() {
let s = TopicService::from_topic("Trade", "Trade");
assert_eq!(
s.matches_path("/zerodds.bridge.v1.TradeStream/Publish"),
Some(MethodKind::Publish)
);
assert_eq!(
s.matches_path("/zerodds.bridge.v1.TradeStream/Subscribe"),
Some(MethodKind::Subscribe)
);
assert_eq!(s.matches_path("/wrong"), None);
}
#[test]
fn render_proto_contains_methods() {
let s = TopicService::from_topic("Trade", "Trade");
let proto = render_proto(&s);
assert!(proto.contains("service TradeStream"));
assert!(proto.contains("rpc Publish(stream Sample)"));
assert!(proto.contains("rpc Subscribe(SubscribeReq)"));
}
#[test]
fn catalog_registration_dedup() {
let mut cat = ServiceCatalog::new();
cat.register(TopicService::from_topic("Trade", "Trade"));
cat.register(TopicService::from_topic("Trade", "Trade"));
assert_eq!(cat.len(), 1);
}
#[test]
fn catalog_find_by_path() {
let mut cat = ServiceCatalog::new();
cat.register(TopicService::from_topic("Trade", "Trade"));
let (svc, kind) = cat
.find_by_path("/zerodds.bridge.v1.TradeStream/Subscribe")
.expect("found");
assert_eq!(svc.service, "TradeStream");
assert_eq!(kind, MethodKind::Subscribe);
}
#[test]
fn catalog_fully_qualified_names() {
let mut cat = ServiceCatalog::new();
cat.register(TopicService::from_topic("Trade", "Trade"));
cat.register(TopicService::from_topic("Quote", "Quote"));
let names = cat.fully_qualified_service_names();
assert_eq!(names.len(), 2);
assert!(names.contains(&"zerodds.bridge.v1.TradeStream".into()));
assert!(names.contains(&"zerodds.bridge.v1.QuoteStream".into()));
}
}