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