use std::net::{IpAddr, SocketAddr};
use super::TransportError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Protocol {
#[default]
Udp,
Tcp,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServiceConfig {
pub service_name: String,
pub instance_name: String,
pub port: u16,
pub addrs: Vec<IpAddr>,
pub txt: Vec<(String, String)>,
pub protocol: Protocol,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BrowseConfig {
pub service_name: String,
pub protocol: Protocol,
}
impl BrowseConfig {
pub fn udp(service_name: impl Into<String>) -> Self {
Self {
service_name: service_name.into(),
protocol: Protocol::Udp,
}
}
}
impl Protocol {
fn label(self) -> &'static str {
match self {
Self::Udp => "_udp",
Self::Tcp => "_tcp",
}
}
}
pub fn service_type(service_name: &str, protocol: Protocol) -> Result<String, TransportError> {
if service_name.is_empty() {
return Err(TransportError::new("service name must not be empty"));
}
if !service_name
.chars()
.all(|character| character.is_ascii_alphanumeric() || character == '-')
{
return Err(TransportError::new(format!(
"{service_name:?} must contain only ASCII letters, digits, and '-'"
)));
}
Ok(format!("_{service_name}.{}.local.", protocol.label()))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServiceRecord {
pub is_active: bool,
pub service_type: String,
pub instance_name: String,
pub host: Option<String>,
pub port: u16,
pub addrs: Vec<SocketAddr>,
pub txt: Vec<(String, String)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RawEvent {
Upsert(ServiceRecord),
Remove {
service_type: String,
instance_name: String,
},
}
impl RawEvent {
pub fn identity(&self) -> (&str, &str) {
match self {
Self::Upsert(record) => (&record.service_type, &record.instance_name),
Self::Remove {
service_type,
instance_name,
} => (service_type, instance_name),
}
}
}
impl From<RawEvent> for ServiceRecord {
fn from(event: RawEvent) -> Self {
match event {
RawEvent::Upsert(mut record) => {
record.is_active = true;
record
}
RawEvent::Remove {
service_type,
instance_name,
} => Self {
is_active: false,
service_type,
instance_name,
host: None,
port: 0,
addrs: Vec::new(),
txt: Vec::new(),
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn upsert_and_remove_have_the_same_explicit_identity() {
let record = ServiceRecord {
is_active: true,
service_type: "_web._tcp.local.".into(),
instance_name: "docs".into(),
host: Some("docs.local.".into()),
port: 443,
addrs: vec![],
txt: vec![("path".into(), "/manual".into())],
};
let upsert = RawEvent::Upsert(record);
let remove = RawEvent::Remove {
service_type: "_web._tcp.local.".into(),
instance_name: "docs".into(),
};
assert_eq!(upsert.identity(), remove.identity());
}
#[test]
fn remove_converts_to_the_public_compatibility_tombstone() {
let record = ServiceRecord::from(RawEvent::Remove {
service_type: "_web._tcp.local.".into(),
instance_name: "docs".into(),
});
assert!(!record.is_active);
assert_eq!(record.service_type, "_web._tcp.local.");
assert_eq!(record.instance_name, "docs");
assert_eq!(record.host, None);
assert_eq!(record.port, 0);
assert!(record.addrs.is_empty());
assert!(record.txt.is_empty());
}
#[test]
fn service_type_preserves_existing_validation_and_protocol_mapping() {
assert!(matches!(
service_type("iroh-http", Protocol::Udp),
Ok(value) if value == "_iroh-http._udp.local."
));
assert!(matches!(
service_type("web", Protocol::Tcp),
Ok(value) if value == "_web._tcp.local."
));
assert!(service_type("bad.name", Protocol::Udp).is_err());
}
}