#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ClusterSection {
pub enabled: bool,
pub port_base: u16,
pub node_id: String,
pub elect_port_base: u16,
pub peers: Vec<PeerEntry>,
pub scopes: Vec<ScopeEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScopeEntry {
pub prefix: Vec<u8>,
pub writer: String,
pub fallback: Option<String>,
}
impl ScopeEntry {
pub fn parse_one(token: &str) -> Option<Self> {
if token.contains(',') {
return None;
}
let (prefix, owners) = token.split_once('=')?;
if prefix.is_empty() || owners.is_empty() {
return None;
}
let (writer, fallback) = match owners.split_once('|') {
Some((w, f)) if !w.is_empty() && !f.is_empty() => (w, Some(f.to_string())),
Some(_) => return None, None => (owners, None),
};
Some(ScopeEntry {
prefix: prefix.as_bytes().to_vec(),
writer: writer.to_string(),
fallback,
})
}
pub fn parse_list(s: &str) -> Result<Vec<ScopeEntry>, String> {
let mut out = Vec::new();
for raw in s.split(',') {
let token = raw.trim();
if token.is_empty() {
continue;
}
match Self::parse_one(token) {
Some(p) => out.push(p),
None => return Err(token.to_string()),
}
}
Ok(out)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PeerEntry {
pub node_id: String,
pub host: String,
pub port: u16,
}
impl PeerEntry {
pub fn parse_one(token: &str) -> Option<Self> {
let (node_id, rest) = token.split_once('@')?;
if node_id.is_empty() {
return None;
}
let colon = rest.rfind(':')?;
let host = &rest[..colon];
if host.is_empty() {
return None;
}
let port: u16 = rest[colon + 1..].parse().ok()?;
Some(PeerEntry {
node_id: node_id.to_string(),
host: host.to_string(),
port,
})
}
pub fn parse_list(s: &str) -> Result<Vec<PeerEntry>, String> {
let mut out = Vec::new();
for raw in s.split(',') {
let token = raw.trim();
if token.is_empty() {
continue;
}
match Self::parse_one(token) {
Some(p) => out.push(p),
None => return Err(token.to_string()),
}
}
Ok(out)
}
}
#[cfg(test)]
mod peer_entry_tests {
use super::*;
#[test]
fn parse_one_basic() {
let p = PeerEntry::parse_one("node-1@10.0.0.1:6004").unwrap();
assert_eq!(p.node_id, "node-1");
assert_eq!(p.host, "10.0.0.1");
assert_eq!(p.port, 6004);
}
#[test]
fn parse_one_dns_host() {
let p = PeerEntry::parse_one("primary@db-east.local:6105").unwrap();
assert_eq!(p.host, "db-east.local");
assert_eq!(p.port, 6105);
}
#[test]
fn parse_one_rejects_empty_id_host_or_bad_port() {
assert!(PeerEntry::parse_one("@host:6004").is_none());
assert!(PeerEntry::parse_one("id@:6004").is_none());
assert!(PeerEntry::parse_one("id@host:NaN").is_none());
assert!(PeerEntry::parse_one("id@host:99999").is_none()); assert!(PeerEntry::parse_one("no-at-or-colon").is_none());
}
#[test]
fn parse_list_three_peers_trim_tolerated() {
let s = "a@1.1.1.1:6004, b@1.1.1.2:6004 ,c@1.1.1.3:6004";
let peers = PeerEntry::parse_list(s).unwrap();
assert_eq!(peers.len(), 3);
assert_eq!(peers[1].node_id, "b");
}
#[test]
fn parse_list_trailing_comma_ok() {
let peers = PeerEntry::parse_list("a@h:1,b@h:2,").unwrap();
assert_eq!(peers.len(), 2);
}
#[test]
fn parse_list_first_bad_token_errs() {
let err = PeerEntry::parse_list("a@h:1,bad-token,c@h:3").unwrap_err();
assert_eq!(err, "bad-token");
}
#[test]
fn parse_list_empty_is_empty() {
assert_eq!(PeerEntry::parse_list("").unwrap(), Vec::<PeerEntry>::new());
assert_eq!(PeerEntry::parse_list(" ").unwrap(), Vec::<PeerEntry>::new());
}
}
#[cfg(test)]
mod scope_entry_tests {
use super::*;
#[test]
fn parse_one_writer_only() {
let s = ScopeEntry::parse_one("app:billing:=embed-billing-1").unwrap();
assert_eq!(s.prefix, b"app:billing:");
assert_eq!(s.writer, "embed-billing-1");
assert_eq!(s.fallback, None);
}
#[test]
fn parse_one_writer_and_fallback() {
let s = ScopeEntry::parse_one("app:billing:=embed-1|fb-server-eu").unwrap();
assert_eq!(s.writer, "embed-1");
assert_eq!(s.fallback.as_deref(), Some("fb-server-eu"));
}
#[test]
fn parse_one_prefix_with_colons() {
let s = ScopeEntry::parse_one("ns:tenant:42:=w").unwrap();
assert_eq!(s.prefix, b"ns:tenant:42:");
}
#[test]
fn parse_one_rejects_empty_prefix_or_writer() {
assert!(ScopeEntry::parse_one("=writer").is_none());
assert!(ScopeEntry::parse_one("prefix=").is_none());
assert!(ScopeEntry::parse_one("no-equals").is_none());
}
#[test]
fn parse_one_rejects_empty_fallback_side() {
assert!(ScopeEntry::parse_one("p=writer|").is_none());
assert!(ScopeEntry::parse_one("p=|fb").is_none());
}
#[test]
fn parse_one_rejects_embedded_comma() {
assert!(ScopeEntry::parse_one("p=writer,other").is_none());
}
#[test]
fn parse_list_two_scopes() {
let v = ScopeEntry::parse_list("app:billing:=w-bill|fb, app:auth:=w-auth").unwrap();
assert_eq!(v.len(), 2);
assert_eq!(v[0].writer, "w-bill");
assert_eq!(v[0].fallback.as_deref(), Some("fb"));
assert_eq!(v[1].writer, "w-auth");
assert!(v[1].fallback.is_none());
}
#[test]
fn parse_list_first_bad_token_errs() {
let err = ScopeEntry::parse_list("p1=w1,no-eq,p3=w3").unwrap_err();
assert_eq!(err, "no-eq");
}
}