1use core::fmt;
27use core::str::FromStr;
28
29#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
31pub enum PeerEndpointError {
32 #[error("peer endpoint must not be empty")]
35 Empty,
36 #[error("peer endpoint {endpoint:?} must be scheme-less host:port, not a http(s):// URL")]
41 SchemeBearing { endpoint: String },
42 #[error("peer endpoint {endpoint:?} must include an explicit :port")]
46 MissingPort { endpoint: String },
47 #[error("peer endpoint {endpoint:?} has an empty host")]
49 EmptyHost { endpoint: String },
50 #[error("peer endpoint {endpoint:?} has an invalid port: {reason}")]
52 InvalidPort {
53 endpoint: String,
54 reason: &'static str,
55 },
56}
57
58#[derive(Clone, Debug, PartialEq, Eq, Hash)]
77pub struct PeerEndpoint(String);
78
79impl PeerEndpoint {
80 pub fn as_str(&self) -> &str {
82 &self.0
83 }
84
85 pub fn into_inner(self) -> String {
87 self.0
88 }
89}
90
91impl TryFrom<String> for PeerEndpoint {
92 type Error = PeerEndpointError;
93
94 fn try_from(endpoint: String) -> Result<Self, Self::Error> {
95 if endpoint.is_empty() {
96 return Err(PeerEndpointError::Empty);
97 }
98 if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
101 return Err(PeerEndpointError::SchemeBearing { endpoint });
102 }
103 let (host, port_str) = match endpoint.rsplit_once(':') {
108 Some(pair) => pair,
109 None => return Err(PeerEndpointError::MissingPort { endpoint }),
110 };
111 if host.is_empty() {
112 return Err(PeerEndpointError::EmptyHost { endpoint });
113 }
114 let port = port_str
117 .parse::<u16>()
118 .map_err(|_| PeerEndpointError::InvalidPort {
119 endpoint: endpoint.clone(),
120 reason: "must be a base-10 number in 1..=65535",
121 })?;
122 if port == 0 {
123 return Err(PeerEndpointError::InvalidPort {
124 endpoint,
125 reason: "port 0 is reserved",
126 });
127 }
128 Ok(PeerEndpoint(endpoint))
129 }
130}
131
132impl TryFrom<&str> for PeerEndpoint {
133 type Error = PeerEndpointError;
134 fn try_from(s: &str) -> Result<Self, Self::Error> {
135 Self::try_from(s.to_string())
136 }
137}
138
139impl FromStr for PeerEndpoint {
140 type Err = PeerEndpointError;
141 fn from_str(s: &str) -> Result<Self, Self::Err> {
142 Self::try_from(s.to_string())
143 }
144}
145
146impl fmt::Display for PeerEndpoint {
147 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148 f.write_str(&self.0)
149 }
150}
151
152impl AsRef<str> for PeerEndpoint {
153 fn as_ref(&self) -> &str {
154 &self.0
155 }
156}
157
158#[cfg(feature = "serde")]
159impl serde::Serialize for PeerEndpoint {
160 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
161 serializer.serialize_str(&self.0)
162 }
163}
164
165#[cfg(feature = "serde")]
166impl<'de> serde::Deserialize<'de> for PeerEndpoint {
167 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
168 let s = String::deserialize(deserializer)?;
172 Self::try_from(s).map_err(serde::de::Error::custom)
173 }
174}
175
176#[derive(Clone, Debug, PartialEq, Eq, Hash)]
186#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
187pub struct TsoPeer {
188 pub node_id: u64,
189 pub endpoint: PeerEndpoint,
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
201 fn carries_node_id_and_endpoint() {
202 let peer = TsoPeer {
203 node_id: 2,
204 endpoint: PeerEndpoint::try_from("node-2:50051").unwrap(),
205 };
206 assert_eq!(peer.node_id, 2);
207 assert_eq!(peer.endpoint.as_str(), "node-2:50051");
208 }
209
210 #[test]
211 fn equality_is_by_node_id_and_endpoint() {
212 let left = TsoPeer {
213 node_id: 1,
214 endpoint: PeerEndpoint::try_from("a:1").unwrap(),
215 };
216 let same = TsoPeer {
217 node_id: 1,
218 endpoint: PeerEndpoint::try_from("a:1").unwrap(),
219 };
220 let different_endpoint = TsoPeer {
221 node_id: 1,
222 endpoint: PeerEndpoint::try_from("b:1").unwrap(),
223 };
224 assert_eq!(left, same);
225 assert_ne!(left, different_endpoint);
226 }
227
228 #[test]
229 fn is_usable_as_a_hash_set_member() {
230 use std::collections::HashSet;
231
232 let mut peers = HashSet::new();
233 peers.insert(TsoPeer {
234 node_id: 1,
235 endpoint: PeerEndpoint::try_from("a:1").unwrap(),
236 });
237 let duplicate = peers.insert(TsoPeer {
238 node_id: 1,
239 endpoint: PeerEndpoint::try_from("a:1").unwrap(),
240 });
241 assert!(!duplicate, "an equal peer must hash to the same bucket");
242 assert_eq!(peers.len(), 1);
243 }
244
245 #[test]
248 fn accepts_basic_hostname_with_port() {
249 let e = PeerEndpoint::try_from("node-1:50051").unwrap();
250 assert_eq!(e.as_str(), "node-1:50051");
251 }
252
253 #[test]
254 fn accepts_ipv4_with_port() {
255 let e = PeerEndpoint::try_from("10.0.0.7:50551").unwrap();
256 assert_eq!(e.as_str(), "10.0.0.7:50551");
257 }
258
259 #[test]
260 fn accepts_ipv6_bracketed_with_port() {
261 let e = PeerEndpoint::try_from("[::1]:50551").unwrap();
262 assert_eq!(e.as_str(), "[::1]:50551");
263 }
264
265 #[test]
266 fn accepts_fqdn_with_port() {
267 let e = PeerEndpoint::try_from("leader.example.com:9000").unwrap();
268 assert_eq!(e.as_str(), "leader.example.com:9000");
269 }
270
271 #[test]
272 fn accepts_max_port() {
273 let e = PeerEndpoint::try_from("h:65535").unwrap();
274 assert_eq!(e.as_str(), "h:65535");
275 }
276
277 #[test]
278 fn accepts_min_valid_port() {
279 let e = PeerEndpoint::try_from("h:1").unwrap();
280 assert_eq!(e.as_str(), "h:1");
281 }
282
283 #[test]
286 fn rejects_empty() {
287 assert_eq!(PeerEndpoint::try_from(""), Err(PeerEndpointError::Empty),);
288 }
289
290 #[test]
291 fn rejects_http_scheme() {
292 let err = PeerEndpoint::try_from("http://node-1:50051").unwrap_err();
293 assert!(
294 matches!(err, PeerEndpointError::SchemeBearing { .. }),
295 "expected SchemeBearing, got {err:?}",
296 );
297 }
298
299 #[test]
300 fn rejects_https_scheme() {
301 let err = PeerEndpoint::try_from("https://node-1:50051").unwrap_err();
302 assert!(
303 matches!(err, PeerEndpointError::SchemeBearing { .. }),
304 "expected SchemeBearing, got {err:?}",
305 );
306 }
307
308 #[test]
309 fn rejects_mem_scheme_via_missing_port() {
310 let err = PeerEndpoint::try_from("mem://foo").unwrap_err();
315 assert!(
316 matches!(
317 err,
318 PeerEndpointError::MissingPort { .. }
319 | PeerEndpointError::InvalidPort { .. }
320 | PeerEndpointError::EmptyHost { .. }
321 ),
322 "expected port-shape rejection, got {err:?}",
323 );
324 }
325
326 #[test]
327 fn rejects_bare_host_no_port() {
328 let err = PeerEndpoint::try_from("node-1").unwrap_err();
329 assert!(
330 matches!(err, PeerEndpointError::MissingPort { .. }),
331 "expected MissingPort, got {err:?}",
332 );
333 }
334
335 #[test]
336 fn rejects_empty_port() {
337 let err = PeerEndpoint::try_from("node-1:").unwrap_err();
338 assert!(
339 matches!(err, PeerEndpointError::InvalidPort { .. }),
340 "expected InvalidPort, got {err:?}",
341 );
342 }
343
344 #[test]
345 fn rejects_non_numeric_port() {
346 let err = PeerEndpoint::try_from("node-1:abc").unwrap_err();
347 assert!(
348 matches!(err, PeerEndpointError::InvalidPort { .. }),
349 "expected InvalidPort, got {err:?}",
350 );
351 }
352
353 #[test]
354 fn rejects_empty_host() {
355 let err = PeerEndpoint::try_from(":50051").unwrap_err();
356 assert!(
357 matches!(err, PeerEndpointError::EmptyHost { .. }),
358 "expected EmptyHost, got {err:?}",
359 );
360 }
361
362 #[test]
363 fn rejects_port_zero() {
364 let err = PeerEndpoint::try_from("h:0").unwrap_err();
365 assert!(
366 matches!(err, PeerEndpointError::InvalidPort { .. }),
367 "expected InvalidPort, got {err:?}",
368 );
369 }
370
371 #[test]
372 fn rejects_port_overflow() {
373 let err = PeerEndpoint::try_from("h:65536").unwrap_err();
374 assert!(
375 matches!(err, PeerEndpointError::InvalidPort { .. }),
376 "expected InvalidPort, got {err:?}",
377 );
378 }
379
380 #[test]
383 fn display_round_trips() {
384 let e = PeerEndpoint::try_from("node-1:50051").unwrap();
385 assert_eq!(format!("{e}"), "node-1:50051");
386 }
387
388 #[test]
389 fn from_str_works() {
390 let e: PeerEndpoint = "node-1:50051".parse().unwrap();
391 assert_eq!(e.as_str(), "node-1:50051");
392 }
393
394 #[test]
395 fn try_from_str_ref_works() {
396 let e: PeerEndpoint = "node-1:50051".try_into().unwrap();
397 assert_eq!(e.as_str(), "node-1:50051");
398 }
399
400 #[test]
401 fn into_inner_returns_string() {
402 let e = PeerEndpoint::try_from("node-1:50051").unwrap();
403 assert_eq!(e.into_inner(), String::from("node-1:50051"));
404 }
405
406 #[cfg(feature = "serde")]
409 #[test]
410 fn serde_round_trip_preserves_value() {
411 let e = PeerEndpoint::try_from("10.0.0.7:50551").unwrap();
412 let json = serde_json::to_string(&e).unwrap();
413 assert_eq!(json, "\"10.0.0.7:50551\"");
414 let back: PeerEndpoint = serde_json::from_str(&json).unwrap();
415 assert_eq!(back, e);
416 }
417
418 #[cfg(feature = "serde")]
419 #[test]
420 fn serde_rejects_scheme_bearing_input() {
421 let err = serde_json::from_str::<PeerEndpoint>("\"http://node-1:50051\"")
424 .expect_err("deserialize must reject scheme-bearing input");
425 let msg = err.to_string();
426 assert!(
427 msg.contains("scheme-less") || msg.contains("http"),
428 "error should mention the scheme contract, got: {msg}",
429 );
430 }
431}