//! Gateway configuration types — pure parsing and validation. //! //! Contains `ProjectEntry`, `GatewayConfig`, and validation logic. //! All filesystem I/O (loading from disk) lives in `io.rs`. use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; /// A single project entry in `projects.toml`. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ProjectEntry { /// Base URL of the project's huskies container (e.g. `http://localhost:3001`). pub url: String, } /// Top-level `projects.toml` config. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct GatewayConfig { /// Map of project name → container URL. #[serde(default)] pub projects: BTreeMap, } /// Validate that a gateway config has at least one project. /// /// Returns the name of the first project (alphabetically) on success, /// or an error message if the config is empty. pub fn validate_config(config: &GatewayConfig) -> Result { if config.projects.is_empty() { return Err("projects.toml must define at least one project".to_string()); } Ok(config.projects.keys().next().unwrap().clone()) } /// Validate that a project name exists in the given project map. /// /// Returns the project's URL on success. pub fn validate_project_exists( projects: &BTreeMap, name: &str, ) -> Result { projects.get(name).map(|p| p.url.clone()).ok_or_else(|| { let available: Vec<&str> = projects.keys().map(|s| s.as_str()).collect(); format!( "unknown project '{name}'. Available: {}", available.join(", ") ) }) } /// Escape a string as a TOML quoted string. pub fn toml_string(s: &str) -> String { format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) } /// Serialize a `bot.toml` content string from the given fields. pub fn serialize_bot_config( transport: &str, homeserver: Option<&str>, username: Option<&str>, password: Option<&str>, slack_bot_token: Option<&str>, slack_signing_secret: Option<&str>, ) -> String { match transport { "slack" => { format!( "enabled = true\ntransport = \"slack\"\n\nslack_bot_token = {}\nslack_signing_secret = {}\nslack_channel_ids = []\n", toml_string(slack_bot_token.unwrap_or("")), toml_string(slack_signing_secret.unwrap_or("")), ) } _ => { format!( "enabled = true\ntransport = \"matrix\"\n\nhomeserver = {}\nusername = {}\npassword = {}\nroom_ids = []\nallowed_users = []\n", toml_string(homeserver.unwrap_or("")), toml_string(username.unwrap_or("")), toml_string(password.unwrap_or("")), ) } } } // ── Tests ──────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; #[test] fn parse_valid_projects_toml() { let toml_str = r#" [projects.huskies] url = "http://localhost:3001" [projects.robot-studio] url = "http://localhost:3002" "#; let config: GatewayConfig = toml::from_str(toml_str).unwrap(); assert_eq!(config.projects.len(), 2); assert_eq!(config.projects["huskies"].url, "http://localhost:3001"); assert_eq!(config.projects["robot-studio"].url, "http://localhost:3002"); } #[test] fn parse_empty_projects_toml() { let toml_str = "[projects]\n"; let config: GatewayConfig = toml::from_str(toml_str).unwrap(); assert!(config.projects.is_empty()); } #[test] fn validate_config_rejects_empty() { let config = GatewayConfig { projects: BTreeMap::new(), }; assert!(validate_config(&config).is_err()); } #[test] fn validate_config_returns_first_project_name() { let mut projects = BTreeMap::new(); projects.insert( "beta".into(), ProjectEntry { url: "http://b".into(), }, ); projects.insert( "alpha".into(), ProjectEntry { url: "http://a".into(), }, ); let config = GatewayConfig { projects }; assert_eq!(validate_config(&config).unwrap(), "alpha"); } #[test] fn validate_project_exists_succeeds() { let mut projects = BTreeMap::new(); projects.insert( "p1".into(), ProjectEntry { url: "http://p1".into(), }, ); assert_eq!( validate_project_exists(&projects, "p1").unwrap(), "http://p1" ); } #[test] fn validate_project_exists_fails() { let projects = BTreeMap::new(); assert!(validate_project_exists(&projects, "missing").is_err()); } #[test] fn toml_string_escapes_quotes() { assert_eq!(toml_string(r#"a"b"#), r#""a\"b""#); } #[test] fn toml_string_escapes_backslashes() { assert_eq!(toml_string(r"a\b"), r#""a\\b""#); } #[test] fn serialize_bot_config_matrix() { let content = serialize_bot_config( "matrix", Some("https://mx.io"), Some("@bot:mx.io"), Some("pass"), None, None, ); assert!(content.contains("transport = \"matrix\"")); assert!(content.contains("homeserver = \"https://mx.io\"")); } #[test] fn serialize_bot_config_slack() { let content = serialize_bot_config("slack", None, None, None, Some("xoxb-123"), Some("secret")); assert!(content.contains("transport = \"slack\"")); assert!(content.contains("slack_bot_token = \"xoxb-123\"")); } }