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) } pub fn load_all_configs(dir: &Path) -> Result, ConfigError> { if !dir.exists() { return Ok(Vec::new()); } let entries = std::fs::read_dir(dir).map_err(|e| ConfigError::Io { path: dir.to_path_buf(), source: e, })?; let mut configs = Vec::new(); for entry in entries { let entry = entry.map_err(|e| ConfigError::Io { path: dir.to_path_buf(), source: e, })?; let path = entry.path(); if path.extension().and_then(|s| s.to_str()) != Some("kdl") { continue; } let name = path .file_stem() .and_then(|s| s.to_str()) .ok_or(ConfigError::InvalidName { name: path.display().to_string(), })? .to_string(); let text = std::fs::read_to_string(&path).map_err(|e| ConfigError::Io { path: path.clone(), source: e, })?; configs.push(parse_server_config(&name, &text, &path)?); } check_duplicate_ports(&configs)?; Ok(configs) } fn check_duplicate_ports(configs: &[ServerConfig]) -> Result<(), ConfigError> { let mut seen: std::collections::HashMap = std::collections::HashMap::new(); for c in configs { if let Some(other) = seen.insert(c.port, c.name.clone()) { return Err(ConfigError::DuplicatePort { name_a: other, name_b: c.name.clone(), port: c.port, }); } } Ok(()) } #[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 { .. })); } use std::fs; use tempfile::tempdir; #[test] fn load_all_finds_and_parses_files() { let dir = tempdir().unwrap(); fs::write(dir.path().join("a.kdl"), "command \"/bin/a\"\nport 8001").unwrap(); fs::write(dir.path().join("b.kdl"), "command \"/bin/b\"\nport 8002").unwrap(); fs::write(dir.path().join("ignored.txt"), "not a config").unwrap(); let mut configs = load_all_configs(dir.path()).unwrap(); configs.sort_by(|x, y| x.name.cmp(&y.name)); assert_eq!(configs.len(), 2); assert_eq!(configs[0].name, "a"); assert_eq!(configs[1].port, 8002); } #[test] fn load_all_returns_empty_for_missing_dir() { let dir = tempdir().unwrap(); let configs = load_all_configs(&dir.path().join("does-not-exist")).unwrap(); assert!(configs.is_empty()); } #[test] fn duplicate_ports_detected() { let dir = tempdir().unwrap(); fs::write(dir.path().join("a.kdl"), "command \"/bin/a\"\nport 8001").unwrap(); fs::write(dir.path().join("b.kdl"), "command \"/bin/b\"\nport 8001").unwrap(); let err = load_all_configs(dir.path()).unwrap_err(); match err { ConfigError::DuplicatePort { port, .. } => assert_eq!(port, 8001), other => panic!("unexpected error: {other:?}"), } } }