Skip to main content

koi_common/
types.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4/// A service instance as seen on the network.
5/// Used in browse results, resolve results, register confirmations,
6/// and event payloads. This is THE service representation across all domains.
7#[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/// Service event kinds for subscribe streams.
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24#[serde(rename_all = "lowercase")]
25pub enum EventKind {
26    Found,
27    Resolved,
28    Removed,
29}
30
31/// Unique identifier for a connection/session.
32#[derive(Debug, Clone, PartialEq, Eq, Hash)]
33pub struct SessionId(String);
34
35impl SessionId {
36    /// Create a new session identifier.
37    pub fn new(id: String) -> Self {
38        Self(id)
39    }
40
41    /// Borrow the inner string.
42    pub fn as_str(&self) -> &str {
43        &self.0
44    }
45}
46
47/// DNS-SD meta-query type for discovering all service types on the network.
48pub const META_QUERY: &str = "_services._dns-sd._udp.local.";
49
50/// Maximum allowed length for DNS-SD service names (RFC 6763).
51const SERVICE_NAME_MAX_LEN: usize = 15;
52
53/// Validated DNS-SD service type.
54#[derive(Debug, Clone, PartialEq, Eq, Hash)]
55pub struct ServiceType(String);
56
57impl ServiceType {
58    /// Parse and normalize a service type string.
59    /// Accepts liberal input: "http", "_http", "_http._tcp", "_http._tcp.local."
60    /// Always produces the canonical form: "_name._tcp.local."
61    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    /// The short form without ".local." for user-facing output.
102    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/// Error parsing a DNS-SD service type string.
114#[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}