use std::fmt;
use crate::error::Error;
#[cfg(feature = "mdns")]
pub mod mdns;
#[cfg(feature = "mdns")]
pub use mdns::MdnsDiscovery;
pub mod tailscale;
pub use tailscale::TailscaleDiscovery;
#[async_trait::async_trait]
pub trait Discovery: Send + Sync + fmt::Debug {
async fn discover(&self) -> Result<Vec<DiscoveredPeer>, Error>;
async fn close(&self) -> Result<(), Error> {
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct DiscoveredPeer {
pub url: String,
pub device_id: Option<String>,
pub latency_ms: Option<u32>,
pub tls_fingerprint: Option<String>,
}
impl DiscoveredPeer {
pub fn new(url: impl Into<String>) -> Self {
Self {
url: url.into(),
device_id: None,
latency_ms: None,
tls_fingerprint: None,
}
}
pub fn with_device_id(mut self, id: impl Into<String>) -> Self {
self.device_id = Some(id.into());
self
}
pub fn with_latency_ms(mut self, ms: u32) -> Self {
self.latency_ms = Some(ms);
self
}
pub fn with_tls_fingerprint(mut self, fp: impl Into<String>) -> Self {
self.tls_fingerprint = Some(normalize_fingerprint(&fp.into()));
self
}
}
pub(crate) fn normalize_fingerprint(raw: &str) -> String {
let trimmed = raw.trim();
let without_prefix = trimmed
.strip_prefix("sha256:")
.or_else(|| trimmed.strip_prefix("SHA256:"))
.unwrap_or(trimmed);
without_prefix
.chars()
.filter(|c| *c != ':')
.map(|c| c.to_ascii_lowercase())
.collect()
}
#[derive(Debug, Clone)]
pub struct Explicit {
urls: Vec<String>,
}
impl Explicit {
pub fn new<S: AsRef<str>>(urls: &[S]) -> Self {
Self {
urls: urls.iter().map(|u| u.as_ref().to_owned()).collect(),
}
}
pub fn from_vec(urls: Vec<String>) -> Self {
Self { urls }
}
pub fn len(&self) -> usize {
self.urls.len()
}
pub fn is_empty(&self) -> bool {
self.urls.is_empty()
}
}
#[async_trait::async_trait]
impl Discovery for Explicit {
async fn discover(&self) -> Result<Vec<DiscoveredPeer>, Error> {
Ok(self.urls.iter().map(DiscoveredPeer::new).collect())
}
}
#[doc(hidden)]
pub use async_trait::async_trait;
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn explicit_returns_exact_list() {
let d = Explicit::new(&["https://a:8443", "https://b:8443"]);
let peers = d.discover().await.expect("discover");
assert_eq!(peers.len(), 2);
assert_eq!(peers[0].url, "https://a:8443");
assert_eq!(peers[1].url, "https://b:8443");
assert!(peers[0].device_id.is_none());
assert!(peers[0].latency_ms.is_none());
}
#[tokio::test]
async fn explicit_empty_is_allowed_here_caller_validates() {
let d = Explicit::from_vec(vec![]);
let peers = d.discover().await.unwrap();
assert!(peers.is_empty());
}
#[tokio::test]
async fn discovered_peer_builder_is_fluent() {
let p = DiscoveredPeer::new("https://x:8443")
.with_device_id("abc")
.with_latency_ms(42)
.with_tls_fingerprint("sha256:AA:BB:CC");
assert_eq!(p.url, "https://x:8443");
assert_eq!(p.device_id.as_deref(), Some("abc"));
assert_eq!(p.latency_ms, Some(42));
assert_eq!(p.tls_fingerprint.as_deref(), Some("aabbcc"));
}
#[test]
fn normalize_fingerprint_handles_prefix_and_case() {
assert_eq!(normalize_fingerprint("sha256:AA:BB"), "aabb");
assert_eq!(normalize_fingerprint("SHA256:AaBb"), "aabb");
assert_eq!(normalize_fingerprint("aabb"), "aabb");
assert_eq!(normalize_fingerprint(" sha256:CC:DD "), "ccdd");
}
}