//! 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`. /// /// Phase 2 (story 899): `url` is now optional — a project served exclusively /// via the sled-uplink WebSocket does not need an HTTP base URL. The `url` /// field is deprecated for removal in a future release; configure /// `auth_token` instead and rely on the WS uplink for all traffic. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ProjectEntry { /// Base URL of the project's huskies container (e.g. `http://localhost:3001`). /// /// **Deprecated** (story 899) — when a sled connects via the uplink WS the /// gateway routes all MCP traffic over that connection instead. The URL is /// used as a fallback when no live uplink exists. Omit for WS-only projects. #[serde(default, skip_serializing_if = "Option::is_none")] pub url: Option, /// Shared-secret token used to authenticate this project's sled when it /// connects to `/api/sled-uplink`. Takes precedence over the top-level /// `[sled_tokens]` table for projects that set this field. #[serde(default, skip_serializing_if = "Option::is_none")] pub auth_token: Option, /// Host-local port for SSH access into the project container. /// /// Set by `new project` (story 1108). The container's SSH server is bound /// to `127.0.0.1::22` so the user can connect with /// `ssh huskies@127.0.0.1 -p -i ~/.huskies//id_ed25519`. #[serde(default, skip_serializing_if = "Option::is_none")] pub ssh_port: Option, } impl ProjectEntry { /// Convenience constructor for entries that only have a URL (e.g. in tests /// and existing `projects.toml` files that have not yet been migrated to /// the WS-uplink model). pub fn with_url(url: impl Into) -> Self { Self { url: Some(url.into()), auth_token: None, ssh_port: None, } } /// Returns `true` if this entry has a configured HTTP base URL. pub fn has_url(&self) -> bool { self.url.as_ref().is_some_and(|u| !u.is_empty()) } } /// Top-level `projects.toml` config. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct GatewayConfig { /// Map of project name → container configuration. #[serde(default)] pub projects: BTreeMap, /// Map of sled_id → shared secret token for sled-uplink authentication. /// /// **Deprecated** (story 899) — move tokens into per-project /// `auth_token` fields instead. The gateway still honours entries here for /// one release to provide a smooth migration window. /// /// Each entry allows a sled identified by `sled_id` to connect to /// `/api/sled-uplink` using the given secret token as a bearer credential. #[serde(default)] pub sled_tokens: 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 (may be empty for WS-uplink-only projects) on /// success. pub fn validate_project_exists( projects: &BTreeMap, name: &str, ) -> Result { projects .get(name) .map(|p| p.url.clone().unwrap_or_default()) .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.as_deref(), Some("http://localhost:3001") ); assert_eq!( config.projects["robot-studio"].url.as_deref(), Some("http://localhost:3002") ); } #[test] fn parse_project_without_url_is_valid() { let toml_str = r#" [projects.ws-only] auth_token = "secret" "#; let config: GatewayConfig = toml::from_str(toml_str).unwrap(); assert_eq!(config.projects.len(), 1); assert!(config.projects["ws-only"].url.is_none()); assert_eq!( config.projects["ws-only"].auth_token.as_deref(), Some("secret") ); } #[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(), sled_tokens: 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::with_url("http://b")); projects.insert("alpha".into(), ProjectEntry::with_url("http://a")); let config = GatewayConfig { projects, sled_tokens: BTreeMap::new(), }; assert_eq!(validate_config(&config).unwrap(), "alpha"); } #[test] fn validate_config_accepts_ws_only_project() { let mut projects = BTreeMap::new(); projects.insert( "ws-only".into(), ProjectEntry { url: None, auth_token: Some("secret".into()), ssh_port: None, }, ); let config = GatewayConfig { projects, sled_tokens: BTreeMap::new(), }; assert!(validate_config(&config).is_ok()); } #[test] fn validate_project_exists_succeeds() { let mut projects = BTreeMap::new(); projects.insert("p1".into(), ProjectEntry::with_url("http://p1")); 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 validate_project_exists_ws_only_returns_empty_url() { let mut projects = BTreeMap::new(); projects.insert( "ws".into(), ProjectEntry { url: None, auth_token: Some("tok".into()), ssh_port: None, }, ); assert_eq!(validate_project_exists(&projects, "ws").unwrap(), ""); } #[test] fn project_entry_with_url_constructor() { let e = ProjectEntry::with_url("http://example.com"); assert_eq!(e.url.as_deref(), Some("http://example.com")); assert!(e.auth_token.is_none()); assert!(e.has_url()); } #[test] fn project_entry_has_url_false_when_none() { let e = ProjectEntry { url: None, auth_token: Some("tok".into()), ssh_port: None, }; assert!(!e.has_url()); } #[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\"")); } #[test] fn roundtrip_project_entry_with_auth_token() { let entry = ProjectEntry { url: Some("http://a:3001".into()), auth_token: Some("mysecret".into()), ssh_port: None, }; let mut projects = BTreeMap::new(); projects.insert("myproj".into(), entry); let config = GatewayConfig { projects, sled_tokens: BTreeMap::new(), }; let toml_str = toml::to_string_pretty(&config).unwrap(); let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap(); assert_eq!( parsed.projects["myproj"].url.as_deref(), Some("http://a:3001") ); assert_eq!( parsed.projects["myproj"].auth_token.as_deref(), Some("mysecret") ); } #[test] fn roundtrip_project_entry_with_ssh_port() { let entry = ProjectEntry { url: Some("http://127.0.0.1:3101".into()), auth_token: None, ssh_port: Some(2201), }; let mut projects = BTreeMap::new(); projects.insert("myproj".into(), entry); let config = GatewayConfig { projects, sled_tokens: BTreeMap::new(), }; let toml_str = toml::to_string_pretty(&config).unwrap(); let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap(); assert_eq!(parsed.projects["myproj"].ssh_port, Some(2201)); // ssh_port must appear in the serialised TOML. assert!( toml_str.contains("ssh_port = 2201"), "ssh_port missing from TOML: {toml_str}" ); } #[test] fn ssh_port_none_is_omitted_from_toml() { let entry = ProjectEntry::with_url("http://127.0.0.1:3101"); let mut projects = BTreeMap::new(); projects.insert("p".into(), entry); let config = GatewayConfig { projects, sled_tokens: BTreeMap::new(), }; let toml_str = toml::to_string_pretty(&config).unwrap(); assert!( !toml_str.contains("ssh_port"), "ssh_port should be omitted when None: {toml_str}" ); } }