use std::borrow::Cow;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SignalId(Cow<'static, str>);
impl SignalId {
pub fn new(id: impl Into<SignalId>) -> Self {
id.into()
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<&str> for SignalId {
fn from(s: &str) -> Self {
Self(Cow::Owned(s.to_owned()))
}
}
impl From<String> for SignalId {
fn from(s: String) -> Self {
Self(Cow::Owned(s))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SemanticRole(Cow<'static, str>);
impl SemanticRole {
pub fn new(role: impl Into<SemanticRole>) -> Self {
role.into()
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<&str> for SemanticRole {
fn from(s: &str) -> Self {
Self(Cow::Owned(s.to_owned()))
}
}
impl From<String> for SemanticRole {
fn from(s: String) -> Self {
Self(Cow::Owned(s))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SchemaRef(Cow<'static, str>);
impl SchemaRef {
pub fn new(schema: impl Into<SchemaRef>) -> Self {
schema.into()
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<&str> for SchemaRef {
fn from(s: &str) -> Self {
Self(Cow::Owned(s.to_owned()))
}
}
impl From<String> for SchemaRef {
fn from(s: String) -> Self {
Self(Cow::Owned(s))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Codec {
Opus,
Aac,
Mp3,
G711Ulaw,
G711Alaw,
WebmOpus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TextFormat {
Utf8,
Json,
Markdown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EventFormat {
Json,
Protobuf,
Flatbuffers,
Cbor,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum BinaryFormat {
Raw,
Protobuf,
Flatbuffers,
Cbor,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum SignalClass {
Any,
PcmAudio,
EncodedAudio(Codec),
Text(TextFormat),
Event(EventFormat),
Metrics,
Control,
Binary(BinaryFormat),
Custom(SignalId),
}
impl SignalClass {
pub fn is_audio(self: &SignalClass) -> bool {
matches!(self, Self::PcmAudio | Self::EncodedAudio(_))
}
pub fn is_compatible_with(&self, other: &SignalClass) -> bool {
matches!(self, Self::Any) || matches!(other, Self::Any) || self == other
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignalSpec {
pub(crate) class: SignalClass,
pub(crate) role: Option<SemanticRole>,
pub(crate) schema: Option<SchemaRef>,
}
impl SignalSpec {
pub const fn class(&self) -> &SignalClass {
&self.class
}
pub const fn role(&self) -> Option<&SemanticRole> {
self.role.as_ref()
}
pub const fn schema(&self) -> Option<&SchemaRef> {
self.schema.as_ref()
}
pub fn new(class: SignalClass) -> Self {
Self {
class,
role: None,
schema: None,
}
}
pub fn wire_id(&self) -> &str {
match &self.class {
SignalClass::Any => "pks.signal.any.v1",
SignalClass::PcmAudio => "pks.signal.pcm-audio.v1",
SignalClass::EncodedAudio(Codec::Opus) => "pks.signal.encoded.opus.v1",
SignalClass::EncodedAudio(Codec::Aac) => "pks.signal.encoded.aac.v1",
SignalClass::EncodedAudio(Codec::Mp3) => "pks.signal.encoded.mp3.v1",
SignalClass::EncodedAudio(Codec::G711Ulaw) => "pks.signal.encoded.g711-ulaw.v1",
SignalClass::EncodedAudio(Codec::G711Alaw) => "pks.signal.encoded.g711-alaw.v1",
SignalClass::EncodedAudio(Codec::WebmOpus) => "pks.signal.encoded.webm-opus.v1",
SignalClass::Text(TextFormat::Utf8) => "pks.signal.text.utf8.v1",
SignalClass::Text(TextFormat::Json) => "pks.signal.text.json.v1",
SignalClass::Text(TextFormat::Markdown) => "pks.signal.text.markdown.v1",
SignalClass::Event(EventFormat::Json) => "pks.signal.event.json.v1",
SignalClass::Event(EventFormat::Protobuf) => "pks.signal.event.protobuf.v1",
SignalClass::Event(EventFormat::Flatbuffers) => "pks.signal.event.flatbuffers.v1",
SignalClass::Event(EventFormat::Cbor) => "pks.signal.event.cbor.v1",
SignalClass::Metrics => "pks.signal.metrics.v1",
SignalClass::Control => "pks.signal.control.v1",
SignalClass::Binary(BinaryFormat::Raw) => "pks.signal.binary.raw.v1",
SignalClass::Binary(BinaryFormat::Protobuf) => "pks.signal.binary.protobuf.v1",
SignalClass::Binary(BinaryFormat::Flatbuffers) => "pks.signal.binary.flatbuffers.v1",
SignalClass::Binary(BinaryFormat::Cbor) => "pks.signal.binary.cbor.v1",
SignalClass::Custom(id) => id.as_str(),
}
}
pub fn any() -> Self {
Self::new(SignalClass::Any)
}
pub fn audio() -> Self {
Self::new(SignalClass::PcmAudio)
}
pub fn encoded_audio(codec: Codec) -> Self {
Self::new(SignalClass::EncodedAudio(codec))
}
pub fn text(format: TextFormat) -> Self {
Self::new(SignalClass::Text(format))
}
pub fn event(format: EventFormat) -> Self {
Self::new(SignalClass::Event(format))
}
pub fn metrics() -> Self {
Self::new(SignalClass::Metrics)
}
pub fn control() -> Self {
Self::new(SignalClass::Control)
}
pub fn binary(format: BinaryFormat) -> Self {
Self::new(SignalClass::Binary(format))
}
pub fn custom(id: impl Into<SignalId>) -> Self {
Self::new(SignalClass::Custom(id.into()))
}
pub fn with_role(mut self, role: impl Into<SemanticRole>) -> Self {
self.role = Some(role.into());
self
}
pub fn with_schema(mut self, schema: impl Into<SchemaRef>) -> Self {
self.schema = Some(schema.into());
self
}
pub fn is_compatible_with(&self, other: &SignalSpec) -> bool {
self.class.is_compatible_with(&other.class)
}
pub fn validate(&self) -> Result<(), SignalSpecError> {
if matches!(&self.class, SignalClass::Custom(id) if id.as_str().trim().is_empty()) {
return Err(SignalSpecError::EmptyCustomId);
}
if self
.role
.as_ref()
.is_some_and(|role| role.as_str().trim().is_empty())
{
return Err(SignalSpecError::EmptyRole);
}
if self
.schema
.as_ref()
.is_some_and(|schema| schema.as_str().trim().is_empty())
{
return Err(SignalSpecError::EmptySchema);
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum SignalSpecError {
#[error("custom signal identifier cannot be empty")]
EmptyCustomId,
#[error("signal semantic role cannot be empty")]
EmptyRole,
#[error("signal schema reference cannot be empty")]
EmptySchema,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn given_two_pcm_audio_specs_when_compatible_check_then_true() {
let a = SignalSpec::audio().with_role("voice");
let b = SignalSpec::audio().with_role("music"); assert!(a.is_compatible_with(&b));
}
#[test]
fn given_audio_and_text_specs_when_compatible_check_then_false() {
let audio = SignalSpec::audio();
let text = SignalSpec::text(TextFormat::Utf8);
assert!(!audio.is_compatible_with(&text));
}
#[test]
fn given_pcm_audio_spec_when_is_audio_then_true() {
assert!(SignalClass::PcmAudio.is_audio());
assert!(SignalClass::EncodedAudio(Codec::Opus).is_audio());
}
#[test]
fn given_text_spec_when_is_audio_then_false() {
assert!(!SignalClass::Text(TextFormat::Utf8).is_audio());
assert!(!SignalClass::Control.is_audio());
assert!(!SignalClass::Metrics.is_audio());
}
#[test]
fn given_custom_signal_with_role_when_built_then_fields_accessible() {
let spec = SignalSpec::custom("com.acme.sentiment.v1")
.with_role("emotion.stress")
.with_schema("proto:acme.Sentiment");
assert_eq!(
spec.class,
SignalClass::Custom(SignalId::new("com.acme.sentiment.v1"))
);
assert_eq!(
spec.role.as_ref().map(|r| r.as_str()),
Some("emotion.stress")
);
assert_eq!(
spec.schema.as_ref().map(|s| s.as_str()),
Some("proto:acme.Sentiment")
);
}
#[test]
fn given_two_different_custom_ids_when_compatible_check_then_false() {
let a = SignalSpec::custom("com.acme.foo.v1");
let b = SignalSpec::custom("com.acme.bar.v1");
assert!(!a.is_compatible_with(&b));
}
#[test]
fn given_same_custom_ids_when_compatible_check_then_true() {
let a = SignalSpec::custom("com.acme.foo.v1");
let b = SignalSpec::custom("com.acme.foo.v1");
assert!(a.is_compatible_with(&b));
}
#[test]
fn given_signal_id_when_as_str_then_returns_inner() {
let id = SignalId::new("com.acme.widget.v2");
assert_eq!(id.as_str(), "com.acme.widget.v2");
}
#[test]
fn given_semantic_role_when_as_str_then_returns_inner() {
let role = SemanticRole::new("transcript.partial");
assert_eq!(role.as_str(), "transcript.partial");
}
#[test]
fn given_empty_custom_id_role_or_schema_when_validated_then_rejected() {
assert_eq!(
SignalSpec::custom("").validate(),
Err(SignalSpecError::EmptyCustomId)
);
assert_eq!(
SignalSpec::control().with_role("").validate(),
Err(SignalSpecError::EmptyRole)
);
assert_eq!(
SignalSpec::binary(BinaryFormat::Raw)
.with_schema("")
.validate(),
Err(SignalSpecError::EmptySchema)
);
}
}