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/// Supported health service-check kinds (wire contract). Lives in the kernel so a
32/// client can request a check without depending on the `koi-health` engine;
33/// `koi-health` re-exports it.
34#[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/// Unique identifier for a connection/session.
42#[derive(Debug, Clone, PartialEq, Eq, Hash)]
43pub struct SessionId(String);
44
45impl SessionId {
46    /// Create a new session identifier.
47    pub fn new(id: String) -> Self {
48        Self(id)
49    }
50
51    /// Borrow the inner string.
52    pub fn as_str(&self) -> &str {
53        &self.0
54    }
55}
56
57/// DNS-SD meta-query type for discovering all service types on the network.
58pub const META_QUERY: &str = "_services._dns-sd._udp.local.";
59
60/// Maximum allowed length for DNS-SD service names (RFC 6763).
61const SERVICE_NAME_MAX_LEN: usize = 15;
62
63/// Validated DNS-SD service type.
64#[derive(Debug, Clone, PartialEq, Eq, Hash)]
65pub struct ServiceType(String);
66
67impl ServiceType {
68    /// Parse and normalize a service type string.
69    /// Accepts liberal input: "http", "_http", "_http._tcp", "_http._tcp.local."
70    /// Always produces the canonical form: "_name._tcp.local."
71    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    /// The short form without ".local." for user-facing output.
112    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/// Error parsing a DNS-SD service type string.
124#[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}