kevy-config 1.19.0

Zero-dependency TOML subset parser and Config schema for kevy.
Documentation
//! `[cluster]` section schema — single-node cluster mode + the
//! v3-cluster Phase 1.5 election peer list. Split out of
//! [`crate::schema`] so that file stays under the 500-LOC house
//! rule.
//!
//! Why the peer list is a flat comma-separated string (per
//! T1.5.4.5 decision (b)): TOML's `[[array_of_tables]]` would be
//! the idiomatic shape, but kevy-config's hand-rolled 0-dep
//! parser doesn't support it (and won't for one feature). A
//! `peers = "id@host:port,id@host:port,..."` value parses with
//! the existing flat KV grammar and the structural future-need
//! is bounded (kevy-elect's anti-scope forbids per-peer TLS,
//! auth, region etc).


/// `[cluster]` section — single-node cluster mode: keys route by
/// Redis-cluster slot (CRC16 `{hashtag}` & 16383) and every shard `i`
/// gets a second, deterministic listener at `port_base + i` that answers
/// wrong-shard keys with `-MOVED`, so stock cluster-aware clients
/// (`redis-benchmark --cluster`, `redis-cli -c`) can address shards
/// directly. The main SO_REUSEPORT port keeps full forward-anywhere
/// behaviour for non-cluster clients. Not hot-settable: the routing
/// scheme is a startup property of the data dir (`shards.meta`).
///
/// `Copy` was dropped in v1.19 once `peers` (a `Vec<PeerEntry>` for
/// `kevy-elect` Phase 1.5) joined this struct. Most call sites just
/// clone the per-tick `Config` snapshot via `Arc<Config>`, so the
/// Copy removal is invisible in the hot path.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ClusterSection {
    /// Enable cluster mode. Default `false` (zero change).
    pub enabled: bool,
    /// First cluster port (shard `i` listens at `port_base + i`).
    /// `0` (default) = `server.port + 1`.
    pub port_base: u16,
    /// This node's stable id for the v3-cluster Phase 1.5
    /// election (≤ 32 B ASCII; unique across the cluster). Default
    /// empty — `kevy-elect` is dormant unless both `node_id` and
    /// `peers` are set (so v1.18-era configs need no edit).
    pub node_id: String,
    /// First election-control listener port; shard `i` binds at
    /// `elect_port_base + i`. Default `0` → `port_base + 100` (or
    /// `server.port + 101` when cluster mode is off).
    pub elect_port_base: u16,
    /// Operator-declared peer list for `kevy-elect`. Empty when
    /// failover is not configured. Each entry is one cluster node
    /// (including potentially *this* node — kevy-elect filters
    /// self by matching `node_id`).
    pub peers: Vec<PeerEntry>,
}

/// One peer in the `kevy-elect` quorum, parsed from the TOML
/// shape `peers = "id@host:port,id@host:port,..."` (per
/// T1.5.4.5 decision (b) — a parser-extension-free representation
/// that works with kevy-config's flat KV-only TOML).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PeerEntry {
    /// Peer's stable node id.
    pub node_id: String,
    /// Peer's host (IPv4 dotted literal or DNS-resolvable name).
    pub host: String,
    /// Peer's election-control port (= peer's
    /// `cluster.elect_port_base + 0`, the shard 0 listener).
    pub port: u16,
}

impl PeerEntry {
    /// Parse one `id@host:port` token. Returns `None` on any shape
    /// problem (empty fields, non-numeric port, port overflow).
    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,
        })
    }

    /// Parse the `peers = "..."` value — a comma-separated list of
    /// `id@host:port` tokens. Empty + all-whitespace tokens are
    /// dropped silently (a trailing comma after the last entry is
    /// tolerated). Returns `Err(token)` on the first unparseable
    /// token, with the offending token in the error for diagnostic.
    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()); // u16 overflow
        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());
    }
}