From e8f5846cec1eb28ea3bc827f2fee9d6bc1de82dd Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 25 May 2026 11:30:38 +0200 Subject: [PATCH] feat(protocol): load_all_configs from dir with duplicate port detection --- crates/xy-protocol/src/kdl_parse.rs | 87 +++++++++++++++++++++++++++++ crates/xy-protocol/src/lib.rs | 2 +- 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/crates/xy-protocol/src/kdl_parse.rs b/crates/xy-protocol/src/kdl_parse.rs index 66f8264..22d3a1f 100644 --- a/crates/xy-protocol/src/kdl_parse.rs +++ b/crates/xy-protocol/src/kdl_parse.rs @@ -259,6 +259,58 @@ 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::*; @@ -357,4 +409,39 @@ stop { 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:?}"), + } + } } diff --git a/crates/xy-protocol/src/lib.rs b/crates/xy-protocol/src/lib.rs index a5e3ed0..64259ab 100644 --- a/crates/xy-protocol/src/lib.rs +++ b/crates/xy-protocol/src/lib.rs @@ -7,5 +7,5 @@ pub mod state; pub use config::{RestartConfig, RestartPolicy, ServerConfig, StopConfig}; pub use error::{ConfigError, RpcErrorCode}; -pub use kdl_parse::parse_server_config; +pub use kdl_parse::{load_all_configs, parse_server_config}; pub use state::ServerState;