1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
8pub struct ServiceRecord {
9 pub name: String,
10 #[serde(rename = "type")]
11 pub service_type: String,
12 #[serde(skip_serializing_if = "Option::is_none")]
13 pub host: Option<String>,
14 #[serde(skip_serializing_if = "Option::is_none")]
15 pub ip: Option<String>,
16 #[serde(skip_serializing_if = "Option::is_none")]
17 pub port: Option<u16>,
18 #[serde(default)]
19 pub txt: HashMap<String, String>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24#[serde(rename_all = "lowercase")]
25pub enum EventKind {
26 Found,
27 Resolved,
28 Removed,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Hash)]
33pub struct SessionId(pub String);
34
35pub const META_QUERY: &str = "_services._dns-sd._udp.local.";
37
38const SERVICE_NAME_MAX_LEN: usize = 15;
40
41#[derive(Debug, Clone, PartialEq, Eq, Hash)]
43pub struct ServiceType(String);
44
45impl ServiceType {
46 pub fn parse(s: &str) -> Result<Self, ServiceTypeError> {
50 let s = s.trim().trim_end_matches('.');
51 let s = s.trim_end_matches(".local");
52
53 let parts: Vec<&str> = s.split('.').collect();
54
55 let (name, proto) = match parts.len() {
56 1 => {
57 let name = parts[0].strip_prefix('_').unwrap_or(parts[0]);
58 (name, "tcp")
59 }
60 2 => {
61 let name = parts[0].strip_prefix('_').unwrap_or(parts[0]);
62 let proto = parts[1].strip_prefix('_').unwrap_or(parts[1]);
63 (name, proto)
64 }
65 _ => return Err(ServiceTypeError::Invalid(s.to_string())),
66 };
67
68 if proto != "tcp" && proto != "udp" {
69 return Err(ServiceTypeError::Invalid(format!(
70 "protocol must be tcp or udp, got '{proto}'"
71 )));
72 }
73
74 if name.is_empty() || name.len() > SERVICE_NAME_MAX_LEN {
75 return Err(ServiceTypeError::Invalid(format!(
76 "service name must be 1-15 characters, got '{name}'"
77 )));
78 }
79
80 let canonical = format!("_{name}._{proto}.local.");
81 tracing::debug!("Normalized service type: \"{s}\" → \"{canonical}\"");
82 Ok(ServiceType(canonical))
83 }
84
85 pub fn as_str(&self) -> &str {
86 &self.0
87 }
88
89 pub fn short(&self) -> &str {
91 self.0.trim_end_matches(".local.").trim_end_matches('.')
92 }
93}
94
95impl std::fmt::Display for ServiceType {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 f.write_str(self.short())
98 }
99}
100
101#[derive(Debug, thiserror::Error)]
103pub enum ServiceTypeError {
104 #[error("Invalid service type: {0}")]
105 Invalid(String),
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 #[test]
113 fn service_type_parse_bare_name() {
114 let st = ServiceType::parse("http").unwrap();
115 assert_eq!(st.as_str(), "_http._tcp.local.");
116 assert_eq!(st.short(), "_http._tcp");
117 }
118
119 #[test]
120 fn service_type_parse_with_underscore() {
121 let st = ServiceType::parse("_http").unwrap();
122 assert_eq!(st.as_str(), "_http._tcp.local.");
123 }
124
125 #[test]
126 fn service_type_parse_full_form() {
127 let st = ServiceType::parse("_http._tcp").unwrap();
128 assert_eq!(st.as_str(), "_http._tcp.local.");
129 }
130
131 #[test]
132 fn service_type_parse_with_trailing_dot() {
133 let st = ServiceType::parse("_http._tcp.").unwrap();
134 assert_eq!(st.as_str(), "_http._tcp.local.");
135 }
136
137 #[test]
138 fn service_type_parse_with_local_dot() {
139 let st = ServiceType::parse("_http._tcp.local.").unwrap();
140 assert_eq!(st.as_str(), "_http._tcp.local.");
141 }
142
143 #[test]
144 fn service_type_parse_udp() {
145 let st = ServiceType::parse("_dns._udp").unwrap();
146 assert_eq!(st.as_str(), "_dns._udp.local.");
147 }
148
149 #[test]
150 fn service_type_rejects_invalid_protocol() {
151 assert!(ServiceType::parse("_http._xyz").is_err());
152 }
153
154 #[test]
155 fn service_type_rejects_empty_name() {
156 assert!(ServiceType::parse("").is_err());
157 }
158
159 #[test]
160 fn service_record_omits_none_fields() {
161 let record = ServiceRecord {
162 name: "Test".into(),
163 service_type: "_http._tcp".into(),
164 host: None,
165 ip: None,
166 port: None,
167 txt: HashMap::new(),
168 };
169 let json = serde_json::to_value(&record).unwrap();
170 assert!(!json.as_object().unwrap().contains_key("host"));
171 assert!(!json.as_object().unwrap().contains_key("ip"));
172 assert!(!json.as_object().unwrap().contains_key("port"));
173 }
174
175 #[test]
176 fn service_record_includes_present_fields() {
177 let record = ServiceRecord {
178 name: "Test".into(),
179 service_type: "_http._tcp".into(),
180 host: Some("server.local".into()),
181 ip: Some("192.168.1.42".into()),
182 port: Some(8080),
183 txt: HashMap::from([("version".into(), "1.0".into())]),
184 };
185 let json = serde_json::to_string(&record).unwrap();
186 assert!(json.contains("\"host\":\"server.local\""));
187 assert!(json.contains("\"ip\":\"192.168.1.42\""));
188 }
189
190 #[test]
191 fn service_record_uses_type_not_service_type_in_json() {
192 let record = ServiceRecord {
193 name: "Test".into(),
194 service_type: "_http._tcp".into(),
195 host: None,
196 ip: None,
197 port: Some(80),
198 txt: HashMap::new(),
199 };
200 let json = serde_json::to_value(&record).unwrap();
201 assert!(json.get("type").is_some());
202 assert!(json.get("service_type").is_none());
203 }
204}