use std::{
borrow::Cow,
cmp::Ordering,
collections::{BTreeMap, BTreeSet, HashSet},
net::IpAddr,
num::NonZeroU32,
sync::Arc,
time::Instant,
};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Registration {
pub name: String,
pub service_type: String,
pub domain: String,
}
impl Registration {
pub fn new(
name: impl Into<String>,
service_type: impl Into<String>,
domain: impl Into<String>,
) -> Self {
Self {
name: name.into(),
service_type: service_type.into(),
domain: domain.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct OccurrenceId(pub NonZeroU32);
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
enum Occurrence {
Named(OccurrenceId),
Endpoint { hostname: String, port: Option<u16> },
Pending,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct EntryId {
registration: Registration,
occurrence: Occurrence,
}
impl EntryId {
pub fn named(registration: Registration, occurrence: OccurrenceId) -> Self {
Self {
registration,
occurrence: Occurrence::Named(occurrence),
}
}
pub fn pending(registration: Registration) -> Self {
Self {
registration,
occurrence: Occurrence::Pending,
}
}
pub fn registration(&self) -> &Registration {
&self.registration
}
pub fn is_pending(&self) -> bool {
matches!(self.occurrence, Occurrence::Pending)
}
}
pub const UNRESOLVED_HOST_LABEL: &str = "<unresolved host>";
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum HostKey {
Resolved(String),
Unresolved,
}
impl HostKey {
fn of(hostname: Option<&str>) -> Self {
match hostname {
Some(host) => HostKey::Resolved(host.to_string()),
None => HostKey::Unresolved,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum EntryGroupId {
LogicalService {
name: String,
service_type: String,
domain: String,
host: HostKey,
port: Option<u16>,
},
Host(HostKey),
ServiceType(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum BrowseMode {
LogicalService,
Host,
ServiceType,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum GroupingMode {
LogicalService,
Host,
ServiceType,
Command,
}
impl GroupingMode {
pub fn browse_mode(self) -> Option<BrowseMode> {
match self {
GroupingMode::LogicalService => Some(BrowseMode::LogicalService),
GroupingMode::Host => Some(BrowseMode::Host),
GroupingMode::ServiceType => Some(BrowseMode::ServiceType),
GroupingMode::Command => None,
}
}
}
impl GroupingMode {
pub const TABS: [GroupingMode; 4] = [
GroupingMode::LogicalService,
GroupingMode::Host,
GroupingMode::ServiceType,
GroupingMode::Command,
];
pub fn label(self) -> &'static str {
match self {
GroupingMode::LogicalService => "logical service",
GroupingMode::Host => "host",
GroupingMode::ServiceType => "service type",
GroupingMode::Command => "command",
}
}
pub fn tab_title(self) -> &'static str {
match self {
GroupingMode::LogicalService => "services",
GroupingMode::Host => "hosts",
GroupingMode::ServiceType => "types",
GroupingMode::Command => "commands",
}
}
}
#[derive(Debug, Clone)]
pub struct Entry {
pub name: String,
pub service_type: String,
pub domain: String,
pub hostname: Option<String>,
pub addresses: Vec<IpAddr>,
pub port: Option<u16>,
pub txt: BTreeMap<String, String>,
pub last_seen: Instant,
occurrence: Option<OccurrenceId>,
}
impl Entry {
pub fn new(
name: impl Into<String>,
service_type: impl Into<String>,
domain: impl Into<String>,
) -> Self {
Self {
name: name.into(),
service_type: service_type.into(),
domain: domain.into(),
hostname: None,
addresses: Vec::new(),
port: None,
txt: BTreeMap::new(),
last_seen: Instant::now(),
occurrence: None,
}
}
pub fn resolved(
name: &str,
service_type: &str,
domain: &str,
hostname: Option<&str>,
addresses: Vec<IpAddr>,
port: Option<u16>,
txt: BTreeMap<String, String>,
) -> Self {
let mut record = Entry::new(name, service_type, domain);
record.hostname = hostname
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string);
record.addresses = addresses;
record.port = port.filter(|port| *port != 0);
record.txt = txt;
record
}
pub fn with_occurrence(mut self, occurrence: Option<OccurrenceId>) -> Self {
self.occurrence = occurrence;
self
}
pub fn occurrence(&self) -> Option<OccurrenceId> {
self.occurrence
}
pub fn id(&self) -> EntryId {
let occurrence = match (self.occurrence, self.has_instance_data()) {
(Some(named), _) => Occurrence::Named(named),
(None, true) => Occurrence::Endpoint {
hostname: self.hostname.clone().unwrap_or_default(),
port: self.port,
},
(None, false) => Occurrence::Pending,
};
EntryId {
registration: self.registration(),
occurrence,
}
}
pub fn registration(&self) -> Registration {
Registration::new(
self.name.clone(),
self.service_type.clone(),
self.domain.clone(),
)
}
pub fn has_instance_data(&self) -> bool {
self.hostname.is_some() || !self.addresses.is_empty() || self.port.is_some()
}
pub fn primary_address(&self) -> Option<IpAddr> {
self.addresses.first().copied()
}
pub(crate) fn field_value(&self, field: &str) -> Option<Cow<'_, str>> {
match field {
"name" => Some(Cow::Borrowed(&self.name)),
"type" | "service_type" => Some(Cow::Borrowed(&self.service_type)),
"domain" => Some(Cow::Borrowed(&self.domain)),
"hostname" => self.hostname.as_deref().map(Cow::Borrowed),
"address" => self
.primary_address()
.map(|value| Cow::Owned(value.to_string())),
"port" => self.port.map(|value| Cow::Owned(value.to_string())),
field => field.strip_prefix("txt.").and_then(|key| {
self.txt
.get(key)
.or_else(|| {
self.txt.iter().find_map(|(candidate, value)| {
candidate.eq_ignore_ascii_case(key).then_some(value)
})
})
.map(|value| Cow::Borrowed(value.as_str()))
}),
}
}
pub(crate) fn has_field(&self, field: &str) -> bool {
match field {
"name" | "type" | "service_type" | "domain" => true,
"hostname" => self.hostname.is_some(),
"address" => self.primary_address().is_some(),
"port" => self.port.is_some(),
field => field.strip_prefix("txt.").is_some_and(|key| {
self.txt.contains_key(key)
|| self
.txt
.keys()
.any(|candidate| candidate.eq_ignore_ascii_case(key))
}),
}
}
pub fn field(&self, field: &str) -> Option<String> {
self.field_value(field).map(Cow::into_owned)
}
pub fn searchable_text(&self) -> String {
let mut text = format!(
"{} {} {} {}",
self.name,
self.display_name(),
self.service_type,
self.domain
);
if let Some(hostname) = &self.hostname {
text.push(' ');
text.push_str(hostname);
}
for address in &self.addresses {
text.push(' ');
text.push_str(&address.to_string());
}
if let Some(port) = self.port {
text.push(' ');
text.push_str(&port.to_string());
}
for (key, value) in &self.txt {
text.push(' ');
text.push_str(key);
text.push(' ');
text.push_str(value);
}
text
}
pub fn display_name(&self) -> String {
decode_dns_sd_escapes(&self.name)
}
pub fn base_display_name(&self) -> String {
let display = self.display_name();
match display.rsplit_once(" [") {
Some((base, rest)) if !base.is_empty() && is_mac_suffix(rest) => base.to_string(),
_ => display,
}
}
}
fn is_mac_suffix(rest: &str) -> bool {
let Some(mac) = rest.strip_suffix(']') else {
return false;
};
let mut groups = 0;
for group in mac.split(':') {
if group.len() != 2 || !group.chars().all(|ch| ch.is_ascii_hexdigit()) {
return false;
}
groups += 1;
}
groups == 6
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogicalService {
pub name: String,
pub service_type: String,
pub domain: String,
pub hostname: Option<String>,
pub port: Option<u16>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostAggregate {
pub hostname: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServiceTypeAggregate {
pub service_type: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GroupFacts {
LogicalService(LogicalService),
Host(HostAggregate),
ServiceType(ServiceTypeAggregate),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RowHost<'a> {
Resolved(&'a str),
Unresolved,
Varies,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RowServiceType<'a> {
Invariant(&'a str),
Varies,
}
impl GroupFacts {
pub fn label(&self) -> String {
match self {
GroupFacts::LogicalService(service) => service.name.clone(),
GroupFacts::Host(host) => host
.hostname
.clone()
.unwrap_or_else(|| UNRESOLVED_HOST_LABEL.to_string()),
GroupFacts::ServiceType(aggregate) => aggregate.service_type.clone(),
}
}
pub fn host(&self) -> RowHost<'_> {
let hostname = match self {
GroupFacts::LogicalService(service) => service.hostname.as_deref(),
GroupFacts::Host(host) => host.hostname.as_deref(),
GroupFacts::ServiceType(_) => return RowHost::Varies,
};
match hostname {
Some(host) => RowHost::Resolved(host),
None => RowHost::Unresolved,
}
}
pub fn service_type(&self) -> RowServiceType<'_> {
match self {
GroupFacts::LogicalService(service) => RowServiceType::Invariant(&service.service_type),
GroupFacts::ServiceType(aggregate) => {
RowServiceType::Invariant(&aggregate.service_type)
}
GroupFacts::Host(_) => RowServiceType::Varies,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TxtValue {
Shared(String),
Mixed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChildService {
pub id: EntryGroupId,
pub facts: LogicalService,
pub occurrences: usize,
}
#[derive(Debug, Clone)]
pub struct EntryGroup {
id: EntryGroupId,
label: String,
facts: GroupFacts,
instances: Arc<[Entry]>,
}
impl EntryGroup {
pub fn id(&self) -> &EntryGroupId {
&self.id
}
pub fn label(&self) -> &str {
&self.label
}
pub fn facts(&self) -> &GroupFacts {
&self.facts
}
pub fn instances(&self) -> &[Entry] {
&self.instances
}
pub fn occurrence_count(&self) -> usize {
self.instances.len()
}
pub fn logical_service_count(&self) -> usize {
browse_row_count(&self.instances, BrowseMode::LogicalService)
}
pub fn resolved_host_count(&self) -> usize {
self.instances
.iter()
.filter_map(|record| record.hostname.as_deref())
.collect::<BTreeSet<_>>()
.len()
}
pub fn service_types(&self) -> Vec<String> {
self.instances
.iter()
.map(|record| record.service_type.clone())
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
pub fn child_services(&self) -> Vec<ChildService> {
let mut buckets: BTreeMap<EntryGroupId, (LogicalService, usize)> = BTreeMap::new();
for record in self.instances.iter() {
let (id, facts) = logical_service_of(record);
let child = buckets.entry(id).or_insert_with(|| (facts, 0));
child.1 += 1;
}
let mut children: Vec<ChildService> = buckets
.into_iter()
.map(|(id, (facts, occurrences))| ChildService {
id,
facts,
occurrences,
})
.collect();
children.sort_by(|a, b| {
a.facts
.name
.cmp(&b.facts.name)
.then_with(|| a.id.cmp(&b.id))
});
children
}
pub fn txt(&self) -> BTreeMap<String, TxtValue> {
let mut merged: BTreeMap<String, TxtValue> = BTreeMap::new();
for record in self.instances.iter() {
for (key, value) in &record.txt {
match merged.get(key) {
None => {
merged.insert(key.clone(), TxtValue::Shared(value.clone()));
}
Some(TxtValue::Shared(existing)) if existing != value => {
merged.insert(key.clone(), TxtValue::Mixed);
}
Some(TxtValue::Shared(_) | TxtValue::Mixed) => {}
}
}
}
for (key, value) in merged.iter_mut() {
if self
.instances
.iter()
.any(|record| !record.txt.contains_key(key))
{
*value = TxtValue::Mixed;
}
}
merged
}
}
pub fn browse_groups(records: &[Entry], mode: BrowseMode) -> Vec<EntryGroup> {
let mut buckets: BTreeMap<EntryGroupId, (GroupFacts, Vec<Entry>)> = BTreeMap::new();
for record in records {
let (id, facts) = row_of(record, mode);
buckets
.entry(id)
.or_insert_with(|| (facts, Vec::new()))
.1
.push(record.clone());
}
groups_from_buckets(buckets)
}
pub(crate) struct BrowseProjection {
pub(crate) groups: Vec<EntryGroup>,
pub(crate) counts: [usize; 3],
}
pub(crate) fn browse_projection(records: &[Entry], active: BrowseMode) -> BrowseProjection {
const MODES: [BrowseMode; 3] = [
BrowseMode::LogicalService,
BrowseMode::Host,
BrowseMode::ServiceType,
];
let active_index = browse_mode_index(active);
let mut identities: [HashSet<EntryGroupId>; 3] = std::array::from_fn(|_| HashSet::new());
let mut buckets: BTreeMap<EntryGroupId, (GroupFacts, Vec<Entry>)> = BTreeMap::new();
for record in records {
for (index, mode) in MODES.into_iter().enumerate() {
let (id, facts) = row_of(record, mode);
if index == active_index {
buckets
.entry(id.clone())
.or_insert_with(|| (facts, Vec::new()))
.1
.push(record.clone());
}
identities[index].insert(id);
}
}
BrowseProjection {
groups: groups_from_buckets(buckets),
counts: identities.map(|ids| ids.len()),
}
}
fn browse_mode_index(mode: BrowseMode) -> usize {
match mode {
BrowseMode::LogicalService => 0,
BrowseMode::Host => 1,
BrowseMode::ServiceType => 2,
}
}
fn groups_from_buckets(
buckets: BTreeMap<EntryGroupId, (GroupFacts, Vec<Entry>)>,
) -> Vec<EntryGroup> {
let mut groups: Vec<EntryGroup> = buckets
.into_iter()
.map(|(id, (facts, mut instances))| {
instances.sort_by(compare_occurrences);
EntryGroup {
label: facts.label(),
id,
facts,
instances: instances.into(),
}
})
.collect();
groups.sort_by(|a, b| a.label.cmp(&b.label).then_with(|| a.id.cmp(&b.id)));
groups
}
pub fn browse_row_count(records: &[Entry], mode: BrowseMode) -> usize {
records
.iter()
.map(|record| row_of(record, mode).0)
.collect::<BTreeSet<_>>()
.len()
}
fn row_of(record: &Entry, mode: BrowseMode) -> (EntryGroupId, GroupFacts) {
match mode {
BrowseMode::LogicalService => {
let (id, facts) = logical_service_of(record);
(id, GroupFacts::LogicalService(facts))
}
BrowseMode::Host => (
EntryGroupId::Host(HostKey::of(record.hostname.as_deref())),
GroupFacts::Host(HostAggregate {
hostname: record.hostname.clone(),
}),
),
BrowseMode::ServiceType => (
EntryGroupId::ServiceType(record.service_type.clone()),
GroupFacts::ServiceType(ServiceTypeAggregate {
service_type: record.service_type.clone(),
}),
),
}
}
fn logical_service_of(record: &Entry) -> (EntryGroupId, LogicalService) {
let facts = LogicalService {
name: record.base_display_name(),
service_type: record.service_type.clone(),
domain: record.domain.clone(),
hostname: record.hostname.clone(),
port: record.port,
};
let id = EntryGroupId::LogicalService {
name: facts.name.clone(),
service_type: facts.service_type.clone(),
domain: facts.domain.clone(),
host: HostKey::of(facts.hostname.as_deref()),
port: facts.port,
};
(id, facts)
}
fn compare_occurrences(a: &Entry, b: &Entry) -> Ordering {
a.name
.cmp(&b.name)
.then_with(|| a.service_type.cmp(&b.service_type))
.then_with(|| a.hostname.cmp(&b.hostname))
.then_with(|| a.addresses.cmp(&b.addresses))
.then_with(|| a.port.cmp(&b.port))
.then_with(|| a.id().cmp(&b.id()))
}
pub fn decode_dns_sd_escapes(value: &str) -> String {
let mut bytes = Vec::with_capacity(value.len());
let mut chars = value.chars().peekable();
while let Some(ch) = chars.next() {
if ch != '\\' {
let mut encoded = [0; 4];
bytes.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes());
continue;
}
let mut digits = String::new();
for _ in 0..3 {
let Some(next) = chars.peek().copied() else {
break;
};
if !next.is_ascii_digit() {
break;
}
digits.push(next);
chars.next();
}
if digits.len() == 3
&& let Ok(byte) = digits.parse::<u8>()
{
bytes.push(byte);
continue;
}
bytes.push(b'\\');
bytes.extend_from_slice(digits.as_bytes());
}
String::from_utf8_lossy(&bytes).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolved_builds_record_from_browse_fields() {
let mut txt = BTreeMap::new();
txt.insert("path".to_string(), "/admin".to_string());
let record = Entry::resolved(
"nas",
"_http._tcp",
"local",
Some("nas.local"),
vec!["192.168.1.30".parse().unwrap()],
Some(8080),
txt,
);
assert_eq!(record.name, "nas");
assert_eq!(record.hostname.as_deref(), Some("nas.local"));
assert_eq!(
record.primary_address(),
Some("192.168.1.30".parse().unwrap())
);
assert_eq!(record.port, Some(8080));
assert_eq!(record.txt.get("path").map(String::as_str), Some("/admin"));
assert!(record.has_instance_data());
}
#[test]
fn resolved_treats_blank_host_and_zero_port_as_unresolved() {
let record = Entry::resolved(
"pending",
"_ipp._tcp",
"local",
Some(""),
Vec::new(),
Some(0),
BTreeMap::new(),
);
assert_eq!(record.hostname, None);
assert!(record.addresses.is_empty());
assert_eq!(record.port, None);
assert!(!record.has_instance_data());
}
#[test]
fn logical_service_keeps_all_its_addresses_on_one_entry() {
let mut entry = Entry::new("host", "_ssh._tcp", "local");
entry.hostname = Some("host.local".to_string());
entry.addresses = vec![
"192.168.1.10".parse().unwrap(),
"192.168.1.11".parse().unwrap(),
];
entry.port = Some(22);
let groups = browse_groups(&[entry], BrowseMode::LogicalService);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].instances().len(), 1);
assert_eq!(groups[0].instances()[0].addresses.len(), 2);
}
#[test]
fn display_name_decodes_avahi_decimal_escapes() {
let record = Entry::new(
r"HP\032OfficeJet\032Pro\0328020\032series\032\0919917FB\093",
"_ipp._tcp",
"local",
);
assert_eq!(
record.display_name(),
"HP OfficeJet Pro 8020 series [9917FB]"
);
assert_eq!(
record.name,
r"HP\032OfficeJet\032Pro\0328020\032series\032\0919917FB\093"
);
}
#[test]
fn display_name_decodes_utf8_byte_escapes() {
let record = Entry::new(r"Caf\195\169", "_http._tcp", "local");
assert_eq!(record.display_name(), "Café");
}
#[test]
fn base_display_name_strips_interface_mac_decoration() {
let per_interface = Entry::new(
r"rpi5-0\032\091d8\0583a\058dd\058f4\058b1\058dc\093",
"_workstation._tcp",
"local",
);
assert_eq!(per_interface.base_display_name(), "rpi5-0");
let printer = Entry::new(r"HP\032Printer\032\0919917FB\093", "_ipp._tcp", "local");
assert_eq!(printer.base_display_name(), "HP Printer [9917FB]");
assert_eq!(
Entry::new("plain", "_ssh._tcp", "local").base_display_name(),
"plain"
);
assert_eq!(
Entry::new("x [d8:3a:dd:f4:b1]", "_ssh._tcp", "local").base_display_name(),
"x [d8:3a:dd:f4:b1]"
);
assert_eq!(
Entry::new("[d8:3a:dd:f4:b1:dc]", "_ssh._tcp", "local").base_display_name(),
"[d8:3a:dd:f4:b1:dc]"
);
}
#[test]
fn per_interface_instances_merge_into_one_logical_service() {
let mut wired = Entry::new("rpi5-0 [d8:3a:dd:f4:b1:dc]", "_workstation._tcp", "local");
wired.hostname = Some("rpi5-0.local".to_string());
wired.addresses = vec!["192.168.50.244".parse().unwrap()];
wired.port = Some(9);
let mut wireless = Entry::new("rpi5-0 [d8:3a:dd:f4:b1:dd]", "_workstation._tcp", "local");
wireless.hostname = Some("rpi5-0.local".to_string());
wireless.addresses = vec!["192.168.50.245".parse().unwrap()];
wireless.port = Some(9);
let groups = browse_groups(&[wired, wireless], BrowseMode::LogicalService);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].label(), "rpi5-0");
assert_eq!(groups[0].instances().len(), 2);
}
#[test]
fn different_services_sharing_an_endpoint_stay_separate() {
let mut site_a = Entry::new("Site A", "_http._tcp", "local");
site_a.hostname = Some("nas.local".to_string());
site_a.port = Some(80);
let mut site_b = Entry::new("Site B", "_http._tcp", "local");
site_b.hostname = Some("nas.local".to_string());
site_b.port = Some(80);
let groups = browse_groups(&[site_a, site_b], BrowseMode::LogicalService);
assert_eq!(groups.len(), 2);
}
#[test]
fn logical_group_label_uses_decoded_display_name() {
let record = Entry::new(r"HP\032Printer", "_ipp._tcp", "local");
let groups = browse_groups(&[record], BrowseMode::LogicalService);
assert_eq!(groups[0].label(), "HP Printer");
}
fn resolved(name: &str, service_type: &str) -> Entry {
let mut record = Entry::new(name, service_type, "local");
record.hostname = Some(format!("{name}.local"));
record.addresses = vec!["192.168.1.10".parse().unwrap()];
record.port = Some(22);
record
}
#[test]
fn field_exposes_all_supported_keys_and_aliases() {
let mut record = Entry::new("alpha", "_ssh._tcp", "local");
record.hostname = Some("alpha.local".to_string());
record.addresses = vec!["192.0.2.5".parse().unwrap()];
record.port = Some(22);
record.txt.insert("path".to_string(), "/admin".to_string());
assert_eq!(record.field("name").as_deref(), Some("alpha"));
assert_eq!(record.field("type").as_deref(), Some("_ssh._tcp"));
assert_eq!(record.field("service_type").as_deref(), Some("_ssh._tcp"));
assert_eq!(record.field("domain").as_deref(), Some("local"));
assert_eq!(record.field("hostname").as_deref(), Some("alpha.local"));
assert_eq!(record.field("address").as_deref(), Some("192.0.2.5"));
assert_eq!(record.field("port").as_deref(), Some("22"));
assert_eq!(record.field("txt.path").as_deref(), Some("/admin"));
assert_eq!(record.field("txt.missing"), None);
assert_eq!(record.field("unknown"), None);
}
#[test]
fn pending_record_has_no_occurrence_identity_until_resolved() {
let pending = Entry::new("alpha", "_ssh._tcp", "local");
assert_eq!(pending.id(), EntryId::pending(pending.registration()));
assert!(pending.id().is_pending());
assert!(!pending.has_instance_data());
let resolved = resolved("alpha", "_ssh._tcp");
assert!(!resolved.id().is_pending());
assert_ne!(resolved.id(), pending.id());
assert!(resolved.has_instance_data());
}
#[test]
fn registration_is_the_name_type_domain_triple() {
let resolved = resolved("alpha", "_ssh._tcp");
assert_eq!(
*resolved.id().registration(),
Registration::new("alpha", "_ssh._tcp", "local")
);
assert_eq!(
resolved.id().registration(),
&Entry::new("alpha", "_ssh._tcp", "local").registration()
);
}
fn on_interface(name: &str, addr: &str, index: u32) -> Entry {
let mut record = Entry::new(name, "_ssh._tcp", "local");
record.hostname = Some(format!("{name}.local"));
record.addresses = vec![addr.parse().unwrap()];
record.port = Some(22);
record.with_occurrence(Some(OccurrenceId(NonZeroU32::new(index).unwrap())))
}
#[test]
fn occurrences_differing_only_by_adapter_name_are_not_equal() {
let wired = on_interface("alpha", "10.0.0.1", 1);
let wireless = on_interface("alpha", "10.0.0.2", 2);
assert_eq!(wired.registration(), wireless.registration());
assert_eq!(wired.hostname, wireless.hostname);
assert_eq!(wired.port, wireless.port);
assert_ne!(wired.id(), wireless.id());
}
#[test]
fn a_named_occurrence_keeps_its_id_when_its_endpoint_or_addresses_change() {
let before = on_interface("alpha", "10.0.0.1", 1);
let mut after = on_interface("alpha", "10.0.0.2", 1);
after.addresses.push("10.0.0.3".parse().unwrap());
after.hostname = Some("moved.local".to_string());
after.port = Some(2222);
after.txt.insert("path".to_string(), "/admin".to_string());
assert_eq!(before.id(), after.id());
}
#[test]
fn without_an_adapter_name_the_endpoint_still_separates_occurrences() {
let mut first = Entry::new("alpha", "_ssh._tcp", "local");
first.hostname = Some("a.local".to_string());
first.port = Some(22);
let mut second = Entry::new("alpha", "_ssh._tcp", "local");
second.hostname = Some("b.local".to_string());
second.port = Some(22);
assert_ne!(first.id(), second.id());
let mut readdressed = first.clone();
readdressed.addresses = vec!["10.0.0.9".parse().unwrap()];
assert_eq!(first.id(), readdressed.id());
}
#[test]
fn occurrences_of_one_registration_group_into_one_logical_service() {
let groups = browse_groups(
&[
on_interface("alpha", "10.0.0.1", 1),
on_interface("alpha", "10.0.0.2", 2),
],
BrowseMode::LogicalService,
);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].label(), "alpha");
assert_eq!(groups[0].instances().len(), 2);
assert_ne!(groups[0].instances()[0].id(), groups[0].instances()[1].id());
}
#[test]
fn separator_characters_in_names_cannot_collide_ids() {
let first = Entry::new("a|b", "c", "local");
let second = Entry::new("a", "b|c", "local");
assert_ne!(first.id(), second.id());
assert_ne!(first.registration(), second.registration());
}
#[test]
fn separator_characters_in_names_cannot_collide_group_keys() {
let first = Entry::new("a|b", "c", "local");
let second = Entry::new("a", "b|c", "local");
let groups = browse_groups(&[first, second], BrowseMode::LogicalService);
assert_eq!(groups.len(), 2);
}
#[test]
fn txt_field_lookup_strips_the_prefix_exactly_once() {
let mut record = Entry::new("nas", "_http._tcp", "local");
record
.txt
.insert("txt.path".to_string(), "nested".to_string());
record.txt.insert("path".to_string(), "plain".to_string());
assert_eq!(record.field("txt.path").as_deref(), Some("plain"));
assert_eq!(record.field("txt.txt.path").as_deref(), Some("nested"));
assert_eq!(record.field("txt.PATH").as_deref(), Some("plain"));
}
#[test]
fn searchable_text_includes_every_instance_field() {
let mut record = Entry::new("alpha", "_ssh._tcp", "local");
record.hostname = Some("alpha.local".to_string());
record.addresses = vec!["192.0.2.5".parse().unwrap()];
record.port = Some(2222);
record
.txt
.insert("note".to_string(), "third floor".to_string());
let text = record.searchable_text();
for needle in [
"alpha",
"_ssh._tcp",
"local",
"alpha.local",
"192.0.2.5",
"2222",
"note",
"third floor",
] {
assert!(text.contains(needle), "missing `{needle}` in `{text}`");
}
}
#[test]
fn grouping_by_host_buckets_records_and_labels_unresolved() {
let a = resolved("alpha", "_ssh._tcp");
let b = resolved("beta", "_http._tcp");
let pending = Entry::new("ghost", "_ipp._tcp", "local");
let groups = browse_groups(&[a, b, pending], BrowseMode::Host);
let labels: Vec<&str> = groups.iter().map(|g| g.label()).collect();
assert!(labels.contains(&"alpha.local"));
assert!(labels.contains(&"beta.local"));
assert!(labels.contains(&"<unresolved host>"));
}
fn service_on(
name: &str,
service_type: &str,
host: &str,
port: u16,
txt: &[(&str, &str)],
) -> Entry {
let mut record = Entry::new(name, service_type, "local");
record.hostname = Some(host.to_string());
record.addresses = vec!["192.0.2.1".parse().unwrap()];
record.port = Some(port);
record.txt = txt
.iter()
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect();
record
}
#[test]
fn a_host_row_states_only_its_host_and_lists_every_service_on_it() {
let shell = service_on("shell", "_ssh._tcp", "nas.local", 22, &[("v", "2")]);
let site = service_on("site", "_http._tcp", "nas.local", 80, &[("path", "/admin")]);
let groups = browse_groups(&[shell, site], BrowseMode::Host);
assert_eq!(groups.len(), 1);
let host = &groups[0];
assert_eq!(host.label(), "nas.local");
assert_eq!(
*host.facts(),
GroupFacts::Host(HostAggregate {
hostname: Some("nas.local".to_string()),
})
);
assert_eq!(host.facts().host(), RowHost::Resolved("nas.local"));
assert_eq!(host.facts().service_type(), RowServiceType::Varies);
let children = host.child_services();
let listed: Vec<(&str, &str, Option<u16>)> = children
.iter()
.map(|child| {
(
child.facts.name.as_str(),
child.facts.service_type.as_str(),
child.facts.port,
)
})
.collect();
assert_eq!(
listed,
vec![
("shell", "_ssh._tcp", Some(22)),
("site", "_http._tcp", Some(80)),
]
);
assert_eq!(host.service_types(), vec!["_http._tcp", "_ssh._tcp"]);
assert_eq!(host.logical_service_count(), 2);
assert_eq!(host.occurrence_count(), 2);
}
#[test]
fn a_service_type_row_states_only_its_type_and_lists_every_host_offering_it() {
let alpha = service_on("alpha", "_ssh._tcp", "alpha.local", 22, &[("os", "linux")]);
let beta = service_on("beta", "_ssh._tcp", "beta.local", 2222, &[("os", "bsd")]);
let pending = Entry::new("ghost", "_ssh._tcp", "local");
let groups = browse_groups(&[alpha, beta, pending], BrowseMode::ServiceType);
assert_eq!(groups.len(), 1);
let by_type = &groups[0];
assert_eq!(by_type.label(), "_ssh._tcp");
assert_eq!(
by_type.facts().service_type(),
RowServiceType::Invariant("_ssh._tcp")
);
assert_eq!(by_type.facts().host(), RowHost::Varies);
let children = by_type.child_services();
let hosts: Vec<Option<&str>> = children
.iter()
.map(|child| child.facts.hostname.as_deref())
.collect();
assert_eq!(hosts, vec![Some("alpha.local"), Some("beta.local"), None]);
let ports: Vec<Option<u16>> = children.iter().map(|child| child.facts.port).collect();
assert_eq!(ports, vec![Some(22), Some(2222), None]);
assert_eq!(by_type.logical_service_count(), 3);
assert_eq!(by_type.occurrence_count(), 3);
assert_eq!(by_type.resolved_host_count(), 2);
}
#[test]
fn duplicate_labels_keep_a_deterministic_order_across_input_permutations() {
let on_nas = service_on("shell", "_ssh._tcp", "nas.local", 22, &[]);
let on_pi = service_on("shell", "_ssh._tcp", "pi.local", 22, &[]);
let other_port = service_on("shell", "_ssh._tcp", "nas.local", 2222, &[]);
let forward = browse_groups(
&[on_nas.clone(), on_pi.clone(), other_port.clone()],
BrowseMode::LogicalService,
);
let reversed = browse_groups(&[other_port, on_pi, on_nas], BrowseMode::LogicalService);
assert_eq!(forward.len(), 3);
assert!(forward.iter().all(|group| group.label() == "shell"));
let ids = |groups: &[EntryGroup]| {
groups
.iter()
.map(|group| group.id().clone())
.collect::<Vec<_>>()
};
assert_eq!(ids(&forward), ids(&reversed));
let endpoints: Vec<(Option<&str>, Option<u16>)> = forward
.iter()
.map(|group| match group.facts() {
GroupFacts::LogicalService(service) => (service.hostname.as_deref(), service.port),
other => panic!("expected a logical service, got {other:?}"),
})
.collect();
assert_eq!(
endpoints,
vec![
(Some("nas.local"), Some(22)),
(Some("nas.local"), Some(2222)),
(Some("pi.local"), Some(22)),
]
);
}
#[test]
fn the_unresolved_host_row_is_an_identity_not_a_label() {
let mut impostor = Entry::new("impostor", "_ssh._tcp", "local");
impostor.hostname = Some(UNRESOLVED_HOST_LABEL.to_string());
impostor.port = Some(22);
let pending = Entry::new("ghost", "_ipp._tcp", "local");
let groups = browse_groups(&[impostor, pending], BrowseMode::Host);
assert_eq!(groups.len(), 2);
assert!(
groups
.iter()
.all(|group| group.label() == UNRESOLVED_HOST_LABEL)
);
assert_eq!(
*groups[0].id(),
EntryGroupId::Host(HostKey::Resolved(UNRESOLVED_HOST_LABEL.to_string()))
);
assert_eq!(
groups[0].facts().host(),
RowHost::Resolved(UNRESOLVED_HOST_LABEL)
);
assert_eq!(*groups[1].id(), EntryGroupId::Host(HostKey::Unresolved));
assert_eq!(groups[1].facts().host(), RowHost::Unresolved);
}
#[test]
fn logical_service_txt_is_shared_only_where_every_occurrence_agrees() {
let mut wired = on_interface("alpha", "10.0.0.1", 1);
wired.txt.insert("model".to_string(), "rpi5".to_string());
wired.txt.insert("iface".to_string(), "eth0".to_string());
wired.txt.insert("wired".to_string(), "yes".to_string());
let mut wireless = on_interface("alpha", "10.0.0.2", 2);
wireless.txt.insert("model".to_string(), "rpi5".to_string());
wireless
.txt
.insert("iface".to_string(), "wlan0".to_string());
let groups = browse_groups(&[wired, wireless], BrowseMode::LogicalService);
assert_eq!(groups.len(), 1);
let txt = groups[0].txt();
assert_eq!(
txt.get("model"),
Some(&TxtValue::Shared("rpi5".to_string()))
);
assert_eq!(txt.get("iface"), Some(&TxtValue::Mixed));
assert_eq!(txt.get("wired"), Some(&TxtValue::Mixed));
}
#[test]
fn row_counts_agree_with_the_rows_each_projection_builds() {
let records = vec![
service_on("shell", "_ssh._tcp", "nas.local", 22, &[]),
service_on("site", "_http._tcp", "nas.local", 80, &[]),
service_on("shell", "_ssh._tcp", "pi.local", 22, &[]),
Entry::new("ghost", "_ipp._tcp", "local"),
];
for mode in [
BrowseMode::LogicalService,
BrowseMode::Host,
BrowseMode::ServiceType,
] {
assert_eq!(
browse_row_count(&records, mode),
browse_groups(&records, mode).len(),
"{mode:?} count must match the rows it would build"
);
}
assert_eq!(browse_row_count(&records, BrowseMode::LogicalService), 4);
assert_eq!(browse_row_count(&records, BrowseMode::Host), 3);
assert_eq!(browse_row_count(&records, BrowseMode::ServiceType), 3);
}
#[test]
fn grouping_by_service_type_merges_same_type() {
let a = resolved("alpha", "_ssh._tcp");
let b = resolved("beta", "_ssh._tcp");
let c = resolved("gamma", "_http._tcp");
let groups = browse_groups(&[a, b, c], BrowseMode::ServiceType);
assert_eq!(groups.len(), 2);
let ssh = groups
.iter()
.find(|g| g.label() == "_ssh._tcp")
.expect("ssh group");
assert_eq!(ssh.instances().len(), 2);
}
#[test]
fn groups_are_sorted_by_label() {
let groups = browse_groups(
&[
resolved("charlie", "_ssh._tcp"),
resolved("alpha", "_ssh._tcp"),
resolved("bravo", "_ssh._tcp"),
],
BrowseMode::LogicalService,
);
let labels: Vec<&str> = groups.iter().map(|g| g.label()).collect();
assert_eq!(labels, vec!["alpha", "bravo", "charlie"]);
}
#[test]
fn decode_handles_incomplete_and_out_of_range_escapes() {
assert_eq!(decode_dns_sd_escapes(r"a\09b"), r"a\09b");
assert_eq!(decode_dns_sd_escapes(r"a\"), r"a\");
assert_eq!(decode_dns_sd_escapes(r"x\999y"), r"x\999y");
}
#[test]
fn only_the_command_tab_lacks_a_browse_projection() {
assert_eq!(
GroupingMode::LogicalService.browse_mode(),
Some(BrowseMode::LogicalService)
);
assert_eq!(GroupingMode::Host.browse_mode(), Some(BrowseMode::Host));
assert_eq!(
GroupingMode::ServiceType.browse_mode(),
Some(BrowseMode::ServiceType)
);
assert_eq!(GroupingMode::Command.browse_mode(), None);
}
}