From 7e59d7d0502999a714f066af18bc4c9c86a61555 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:29:05 +0200 Subject: [PATCH] feat(protocol): KDL parser for ServerConfig Adds kdl_parse module with parse_server_config() that deserialises a KDL document into ServerConfig, with full validation of name, types, durations, and restart/stop blocks. Also derives Default on RestartPolicy to satisfy clippy. --- crates/xy-protocol/src/config.rs | 9 +- crates/xy-protocol/src/error.rs | 22 +- crates/xy-protocol/src/kdl_parse.rs | 360 ++++++++++++++++++++++++++++ crates/xy-protocol/src/lib.rs | 2 + 4 files changed, 382 insertions(+), 11 deletions(-) create mode 100644 crates/xy-protocol/src/kdl_parse.rs diff --git a/crates/xy-protocol/src/config.rs b/crates/xy-protocol/src/config.rs index a4cab81..7780918 100644 --- a/crates/xy-protocol/src/config.rs +++ b/crates/xy-protocol/src/config.rs @@ -1,20 +1,15 @@ use serde::{Deserialize, Serialize}; use std::time::Duration; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum RestartPolicy { Always, + #[default] OnFailure, Never, } -impl Default for RestartPolicy { - fn default() -> Self { - RestartPolicy::OnFailure - } -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RestartConfig { #[serde(default)] diff --git a/crates/xy-protocol/src/error.rs b/crates/xy-protocol/src/error.rs index 4a0ffba..3141096 100644 --- a/crates/xy-protocol/src/error.rs +++ b/crates/xy-protocol/src/error.rs @@ -4,7 +4,11 @@ use thiserror::Error; #[derive(Debug, Error)] pub enum ConfigError { #[error("failed to read {path}: {source}")] - Io { path: PathBuf, #[source] source: std::io::Error }, + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, #[error("failed to parse KDL in {path}: {message}")] Parse { path: PathBuf, message: String }, @@ -13,10 +17,18 @@ pub enum ConfigError { MissingField { path: PathBuf, field: &'static str }, #[error("invalid value for `{field}` in {path}: {message}")] - InvalidValue { path: PathBuf, field: &'static str, message: String }, + InvalidValue { + path: PathBuf, + field: &'static str, + message: String, + }, #[error("duplicate port {port} declared by both `{name_a}` and `{name_b}`")] - DuplicatePort { name_a: String, name_b: String, port: u16 }, + DuplicatePort { + name_a: String, + name_b: String, + port: u16, + }, #[error("server name `{name}` contains invalid characters (allowed: a-z, 0-9, '-', '_')")] InvalidName { name: String }, @@ -33,5 +45,7 @@ pub enum RpcErrorCode { } impl RpcErrorCode { - pub fn as_i32(self) -> i32 { self as i32 } + pub fn as_i32(self) -> i32 { + self as i32 + } } diff --git a/crates/xy-protocol/src/kdl_parse.rs b/crates/xy-protocol/src/kdl_parse.rs new file mode 100644 index 0000000..66f8264 --- /dev/null +++ b/crates/xy-protocol/src/kdl_parse.rs @@ -0,0 +1,360 @@ +use crate::{ConfigError, RestartConfig, RestartPolicy, ServerConfig, StopConfig}; +use kdl::{KdlDocument, KdlNode}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +pub fn parse_server_config( + name: &str, + text: &str, + source_path: &Path, +) -> Result { + validate_name(name).map_err(|_| ConfigError::InvalidName { + name: name.to_string(), + })?; + + let doc: KdlDocument = text + .parse() + .map_err(|err: kdl::KdlError| ConfigError::Parse { + path: source_path.to_path_buf(), + message: err.to_string(), + })?; + + let command = require_string_arg(&doc, "command", source_path)?; + let args = optional_string_args(&doc, "args"); + let port = require_u16_arg(&doc, "port", source_path)?; + let env = optional_string_map(&doc, "env"); + let working_dir = optional_string_arg(&doc, "working-dir").map(PathBuf::from); + let restart = parse_restart(&doc, source_path)?; + let stop = parse_stop(&doc, source_path)?; + + Ok(ServerConfig { + name: name.to_string(), + command: PathBuf::from(command), + args, + port, + env, + working_dir, + restart, + stop, + }) +} + +fn validate_name(name: &str) -> Result<(), ()> { + if name.is_empty() { + return Err(()); + } + + if name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') + { + Ok(()) + } else { + Err(()) + } +} + +fn require_string_arg( + doc: &KdlDocument, + name: &'static str, + path: &Path, +) -> Result { + let node = find_node(doc, name).ok_or(ConfigError::MissingField { + path: path.to_path_buf(), + field: name, + })?; + + node.entries() + .first() + .and_then(|e| e.value().as_string().map(str::to_string)) + .ok_or(ConfigError::InvalidValue { + path: path.to_path_buf(), + field: name, + message: "expected string argument".into(), + }) +} + +fn require_u16_arg(doc: &KdlDocument, name: &'static str, path: &Path) -> Result { + let node = find_node(doc, name).ok_or(ConfigError::MissingField { + path: path.to_path_buf(), + field: name, + })?; + + let v = node + .entries() + .first() + .and_then(|e| e.value().as_integer()) + .ok_or(ConfigError::InvalidValue { + path: path.to_path_buf(), + field: name, + message: "expected integer".into(), + })?; + + u16::try_from(v).map_err(|_| ConfigError::InvalidValue { + path: path.to_path_buf(), + field: name, + message: format!("port {v} out of u16 range"), + }) +} + +fn optional_string_arg(doc: &KdlDocument, name: &str) -> Option { + find_node(doc, name) + .and_then(|n| n.entries().first()) + .and_then(|e| e.value().as_string().map(str::to_string)) +} + +fn optional_string_args(doc: &KdlDocument, name: &str) -> Vec { + find_node(doc, name) + .map(|n| { + n.entries() + .iter() + .filter_map(|e| e.value().as_string().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +fn optional_string_map(doc: &KdlDocument, name: &str) -> BTreeMap { + let Some(node) = find_node(doc, name) else { + return BTreeMap::new(); + }; + + let mut out = BTreeMap::new(); + + if let Some(children) = node.children() { + for child in children.nodes() { + let key = child.name().value().to_string(); + + if let Some(val) = child.entries().first().and_then(|e| e.value().as_string()) { + out.insert(key, val.to_string()); + } + } + } + + out +} + +fn parse_restart(doc: &KdlDocument, path: &Path) -> Result { + let Some(node) = find_node(doc, "restart") else { + return Ok(RestartConfig::default()); + }; + + let Some(children) = node.children() else { + return Ok(RestartConfig::default()); + }; + + let mut out = RestartConfig::default(); + + for child in children.nodes() { + match child.name().value() { + "policy" => { + let s = string_arg(child, "policy", path)?; + + out.policy = match s.as_str() { + "always" => RestartPolicy::Always, + "on-failure" => RestartPolicy::OnFailure, + "never" => RestartPolicy::Never, + other => { + return Err(ConfigError::InvalidValue { + path: path.to_path_buf(), + field: "restart.policy", + message: format!("unknown policy `{other}`"), + }); + } + }; + } + "backoff-initial" => { + out.backoff_initial = parse_duration_arg(child, "restart.backoff-initial", path)?; + } + "backoff-max" => { + out.backoff_max = parse_duration_arg(child, "restart.backoff-max", path)?; + } + "max-retries-per-minute" => { + let v = child + .entries() + .first() + .and_then(|e| e.value().as_integer()) + .ok_or(ConfigError::InvalidValue { + path: path.to_path_buf(), + field: "restart.max-retries-per-minute", + message: "expected integer".into(), + })?; + + out.max_retries_per_minute = + u32::try_from(v).map_err(|_| ConfigError::InvalidValue { + path: path.to_path_buf(), + field: "restart.max-retries-per-minute", + message: format!("out of u32 range: {v}"), + })?; + } + other => { + return Err(ConfigError::InvalidValue { + path: path.to_path_buf(), + field: "restart", + message: format!("unknown key `{other}`"), + }); + } + } + } + + Ok(out) +} + +fn parse_stop(doc: &KdlDocument, path: &Path) -> Result { + let Some(node) = find_node(doc, "stop") else { + return Ok(StopConfig::default()); + }; + + let Some(children) = node.children() else { + return Ok(StopConfig::default()); + }; + + let mut out = StopConfig::default(); + + for child in children.nodes() { + match child.name().value() { + "grace" => { + out.grace = parse_duration_arg(child, "stop.grace", path)?; + } + other => { + return Err(ConfigError::InvalidValue { + path: path.to_path_buf(), + field: "stop", + message: format!("unknown key `{other}`"), + }); + } + } + } + + Ok(out) +} + +fn string_arg(node: &KdlNode, field: &'static str, path: &Path) -> Result { + node.entries() + .first() + .and_then(|e| e.value().as_string().map(str::to_string)) + .ok_or(ConfigError::InvalidValue { + path: path.to_path_buf(), + field, + message: "expected string".into(), + }) +} + +fn parse_duration_arg( + node: &KdlNode, + field: &'static str, + path: &Path, +) -> Result { + let s = string_arg(node, field, path)?; + + humantime::parse_duration(&s).map_err(|err| ConfigError::InvalidValue { + path: path.to_path_buf(), + field, + message: format!("invalid duration `{s}`: {err}"), + }) +} + +fn find_node<'a>(doc: &'a KdlDocument, name: &str) -> Option<&'a KdlNode> { + doc.nodes().iter().find(|n| n.name().value() == name) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + fn p() -> &'static Path { + Path::new("/tmp/test.kdl") + } + + #[test] + fn parses_minimal_config() { + let text = "command \"/usr/local/bin/foo\"\nport 8421\n"; + let cfg = parse_server_config("foo", text, p()).unwrap(); + + assert_eq!(cfg.name, "foo"); + assert_eq!(cfg.command, PathBuf::from("/usr/local/bin/foo")); + assert_eq!(cfg.port, 8421); + assert!(cfg.args.is_empty()); + assert!(cfg.env.is_empty()); + } + + #[test] + fn parses_full_config() { + let text = r#" +command "/usr/local/bin/foo" +args "--http" "--port" "8421" +port 8421 +env { + RUST_LOG "info" + FOO_BAR "baz" +} +working-dir "/tmp/work" +restart { + policy "always" + backoff-initial "2s" + backoff-max "1m" + max-retries-per-minute 10 +} +stop { + grace "30s" +} +"#; + let cfg = parse_server_config("foo", text, p()).unwrap(); + + assert_eq!(cfg.args, vec!["--http", "--port", "8421"]); + assert_eq!(cfg.env.get("RUST_LOG").map(String::as_str), Some("info")); + assert_eq!(cfg.working_dir, Some(PathBuf::from("/tmp/work"))); + assert_eq!(cfg.restart.policy, RestartPolicy::Always); + assert_eq!(cfg.restart.backoff_initial, Duration::from_secs(2)); + assert_eq!(cfg.restart.backoff_max, Duration::from_secs(60)); + assert_eq!(cfg.restart.max_retries_per_minute, 10); + assert_eq!(cfg.stop.grace, Duration::from_secs(30)); + } + + #[test] + fn missing_command_fails() { + let err = parse_server_config("foo", "port 8421", p()).unwrap_err(); + + assert!(matches!( + err, + ConfigError::MissingField { + field: "command", + .. + } + )); + } + + #[test] + fn missing_port_fails() { + let err = parse_server_config("foo", "command \"/bin/x\"", p()).unwrap_err(); + + assert!(matches!( + err, + ConfigError::MissingField { field: "port", .. } + )); + } + + #[test] + fn unknown_restart_policy_fails() { + let text = "command \"/bin/x\"\nport 1\nrestart { policy \"maybe\" }"; + let err = parse_server_config("foo", text, p()).unwrap_err(); + + assert!(matches!( + err, + ConfigError::InvalidValue { + field: "restart.policy", + .. + } + )); + } + + #[test] + fn invalid_name_rejected() { + let text = "command \"/bin/x\"\nport 1"; + let err = parse_server_config("Foo Bar", text, p()).unwrap_err(); + + assert!(matches!(err, ConfigError::InvalidName { .. })); + } +} diff --git a/crates/xy-protocol/src/lib.rs b/crates/xy-protocol/src/lib.rs index 6c933ad..a5e3ed0 100644 --- a/crates/xy-protocol/src/lib.rs +++ b/crates/xy-protocol/src/lib.rs @@ -2,8 +2,10 @@ pub mod config; pub mod error; +pub mod kdl_parse; pub mod state; pub use config::{RestartConfig, RestartPolicy, ServerConfig, StopConfig}; pub use error::{ConfigError, RpcErrorCode}; +pub use kdl_parse::parse_server_config; pub use state::ServerState;