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