diff --git a/server/src/gateway.rs b/server/src/gateway.rs new file mode 100644 index 00000000..dfdb43be --- /dev/null +++ b/server/src/gateway.rs @@ -0,0 +1,672 @@ +//! Multi-project gateway — proxies MCP calls to per-project Docker containers. +//! +//! When `huskies --gateway` is used, the server starts in gateway mode: it reads +//! a `projects.toml` config that maps project names to container URLs, maintains +//! an "active project" selection, and proxies all MCP tool calls to the active +//! project's container. Gateway-specific tools allow switching projects, querying +//! status, and aggregating health checks across all registered projects. + +use poem::EndpointExt; +use poem::handler; +use poem::http::StatusCode; +use poem::web::Data; +use poem::{Body, Request, Response}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::path::Path; +use std::sync::Arc; +use tokio::sync::RwLock; + +// ── Config ─────────────────────────────────────────────────────────── + +/// 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, +} + +impl GatewayConfig { + /// Load gateway config from a `projects.toml` file. + pub fn load(path: &Path) -> Result { + let contents = std::fs::read_to_string(path) + .map_err(|e| format!("cannot read {}: {e}", path.display()))?; + toml::from_str(&contents) + .map_err(|e| format!("invalid projects.toml: {e}")) + } +} + +// ── Gateway state ──────────────────────────────────────────────────── + +/// Shared gateway state threaded through HTTP handlers. +#[derive(Clone)] +pub struct GatewayState { + /// The parsed gateway config with all registered projects. + pub config: GatewayConfig, + /// The currently active project name. + pub active_project: Arc>, + /// HTTP client for proxying requests to project containers. + pub client: Client, +} + +impl GatewayState { + /// Create a new gateway state from a config. The first project in the config + /// becomes the active project by default. + pub fn new(config: GatewayConfig) -> Result { + if config.projects.is_empty() { + return Err("projects.toml must define at least one project".to_string()); + } + let first = config.projects.keys().next().unwrap().clone(); + Ok(Self { + config, + active_project: Arc::new(RwLock::new(first)), + client: Client::new(), + }) + } + + /// Get the URL of the currently active project. + async fn active_url(&self) -> Result { + let name = self.active_project.read().await.clone(); + self.config + .projects + .get(&name) + .map(|p| p.url.clone()) + .ok_or_else(|| format!("active project '{name}' not found in config")) + } +} + +// ── MCP proxy handler ──────────────────────────────────────────────── + +/// JSON-RPC request (duplicated here to keep the gateway self-contained). +#[derive(Deserialize)] +struct JsonRpcRequest { + jsonrpc: String, + id: Option, + method: String, + #[serde(default)] + params: Value, +} + +/// JSON-RPC response. +#[derive(Serialize)] +struct JsonRpcResponse { + jsonrpc: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Serialize)] +struct JsonRpcError { + code: i64, + message: String, +} + +impl JsonRpcResponse { + fn success(id: Option, result: Value) -> Self { + Self { jsonrpc: "2.0", id, result: Some(result), error: None } + } + + fn error(id: Option, code: i64, message: String) -> Self { + Self { jsonrpc: "2.0", id, result: None, error: Some(JsonRpcError { code, message }) } + } +} + +fn to_json_response(resp: JsonRpcResponse) -> Response { + let body = serde_json::to_vec(&resp).unwrap_or_default(); + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .body(Body::from(body)) +} + +/// Gateway-specific MCP tools exposed alongside the proxied tools. +const GATEWAY_TOOLS: &[&str] = &["switch_project", "gateway_status", "gateway_health"]; + +/// Main MCP POST handler for the gateway. Intercepts gateway-specific tools and +/// proxies everything else to the active project's container. +#[handler] +pub async fn gateway_mcp_post_handler( + req: &Request, + body: Body, + state: Data<&Arc>, +) -> Response { + let content_type = req.header("content-type").unwrap_or(""); + if !content_type.is_empty() && !content_type.contains("application/json") { + return to_json_response(JsonRpcResponse::error( + None, -32700, "Unsupported Content-Type; expected application/json".into(), + )); + } + + let bytes = match body.into_bytes().await { + Ok(b) => b, + Err(_) => return to_json_response(JsonRpcResponse::error(None, -32700, "Parse error".into())), + }; + + let rpc: JsonRpcRequest = match serde_json::from_slice(&bytes) { + Ok(r) => r, + Err(_) => return to_json_response(JsonRpcResponse::error(None, -32700, "Parse error".into())), + }; + + if rpc.jsonrpc != "2.0" { + return to_json_response(JsonRpcResponse::error(rpc.id, -32600, "Invalid JSON-RPC version".into())); + } + + // Accept notifications silently. + if rpc.id.is_none() || rpc.id.as_ref() == Some(&Value::Null) { + if rpc.method.starts_with("notifications/") { + return Response::builder() + .status(StatusCode::ACCEPTED) + .body(Body::empty()); + } + return to_json_response(JsonRpcResponse::error(None, -32600, "Missing id".into())); + } + + match rpc.method.as_str() { + "initialize" => to_json_response(handle_initialize(rpc.id)), + "tools/list" => { + // Merge gateway tools with proxied tools from the active project. + match merge_tools_list(&state, rpc.id.clone()).await { + Ok(resp) => to_json_response(resp), + Err(e) => to_json_response(JsonRpcResponse::error(rpc.id, -32603, e)), + } + } + "tools/call" => { + let tool_name = rpc.params + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if GATEWAY_TOOLS.contains(&tool_name) { + to_json_response(handle_gateway_tool(tool_name, &rpc.params, &state).await) + } else { + // Proxy to active project's container. + match proxy_mcp_call(&state, &bytes).await { + Ok(resp_body) => Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .body(Body::from(resp_body)), + Err(e) => to_json_response(JsonRpcResponse::error( + rpc.id, -32603, format!("proxy error: {e}"), + )), + } + } + } + _ => { + // Proxy unknown methods too. + match proxy_mcp_call(&state, &bytes).await { + Ok(resp_body) => Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .body(Body::from(resp_body)), + Err(e) => to_json_response(JsonRpcResponse::error( + rpc.id, -32603, format!("proxy error: {e}"), + )), + } + } + } +} + +/// GET handler — method not allowed (matches the regular MCP endpoint behavior). +#[handler] +pub async fn gateway_mcp_get_handler() -> Response { + Response::builder() + .status(StatusCode::METHOD_NOT_ALLOWED) + .body(Body::empty()) +} + +// ── Protocol handlers ──────────────────────────────────────────────── + +fn handle_initialize(id: Option) -> JsonRpcResponse { + JsonRpcResponse::success( + id, + json!({ + "protocolVersion": "2025-03-26", + "capabilities": { "tools": {} }, + "serverInfo": { + "name": "huskies-gateway", + "version": "1.0.0" + } + }), + ) +} + +/// Gateway tool definitions. +fn gateway_tool_definitions() -> Vec { + vec![ + json!({ + "name": "switch_project", + "description": "Switch the active project. All subsequent MCP tool calls will be proxied to this project's container.", + "inputSchema": { + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "Name of the project to switch to (must exist in projects.toml)" + } + }, + "required": ["project"] + } + }), + json!({ + "name": "gateway_status", + "description": "Show pipeline status for the active project by proxying the get_pipeline_status tool call.", + "inputSchema": { + "type": "object", + "properties": {} + } + }), + json!({ + "name": "gateway_health", + "description": "Health check aggregation across all registered projects. Returns the health status of every project container.", + "inputSchema": { + "type": "object", + "properties": {} + } + }), + ] +} + +/// Fetch tools/list from the active project and merge in gateway tools. +async fn merge_tools_list( + state: &GatewayState, + id: Option, +) -> Result { + let url = state.active_url().await?; + let mcp_url = format!("{}/mcp", url.trim_end_matches('/')); + + let rpc_body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": {} + }); + + let resp = state.client + .post(&mcp_url) + .json(&rpc_body) + .send() + .await + .map_err(|e| format!("failed to reach {mcp_url}: {e}"))?; + + let resp_json: Value = resp.json().await + .map_err(|e| format!("invalid JSON from upstream: {e}"))?; + + let mut tools: Vec = resp_json + .get("result") + .and_then(|r| r.get("tools")) + .and_then(|t| t.as_array()) + .cloned() + .unwrap_or_default(); + + // Prepend gateway-specific tools. + let mut all_tools = gateway_tool_definitions(); + all_tools.append(&mut tools); + + Ok(JsonRpcResponse::success(id, json!({ "tools": all_tools }))) +} + +/// Proxy a raw MCP request body to the active project's container. +async fn proxy_mcp_call( + state: &GatewayState, + request_bytes: &[u8], +) -> Result, String> { + let url = state.active_url().await?; + let mcp_url = format!("{}/mcp", url.trim_end_matches('/')); + + let resp = state.client + .post(&mcp_url) + .header("Content-Type", "application/json") + .body(request_bytes.to_vec()) + .send() + .await + .map_err(|e| format!("failed to reach {mcp_url}: {e}"))?; + + resp.bytes() + .await + .map(|b| b.to_vec()) + .map_err(|e| format!("failed to read response from {mcp_url}: {e}")) +} + +// ── Gateway-specific tools ─────────────────────────────────────────── + +/// Dispatch a gateway-specific tool call. +async fn handle_gateway_tool( + tool_name: &str, + params: &Value, + state: &GatewayState, +) -> JsonRpcResponse { + let id = None; // The caller wraps this in a proper response. + match tool_name { + "switch_project" => handle_switch_project(params, state).await, + "gateway_status" => handle_gateway_status(state).await, + "gateway_health" => handle_gateway_health(state).await, + _ => JsonRpcResponse::error(id, -32601, format!("Unknown gateway tool: {tool_name}")), + } +} + +/// Switch the active project. +async fn handle_switch_project(params: &Value, state: &GatewayState) -> JsonRpcResponse { + let project = params + .get("arguments") + .and_then(|a| a.get("project")) + .or_else(|| params.get("project")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if project.is_empty() { + return JsonRpcResponse::error(None, -32602, "missing required parameter: project".into()); + } + + if !state.config.projects.contains_key(project) { + let available: Vec<&str> = state.config.projects.keys().map(|s| s.as_str()).collect(); + return JsonRpcResponse::error( + None, -32602, + format!("unknown project '{project}'. Available: {}", available.join(", ")), + ); + } + + *state.active_project.write().await = project.to_string(); + + let url = &state.config.projects[project].url; + JsonRpcResponse::success( + None, + json!({ + "content": [{ + "type": "text", + "text": format!("Switched to project '{project}' ({})", url) + }] + }), + ) +} + +/// Show pipeline status for the active project by proxying `get_pipeline_status`. +async fn handle_gateway_status(state: &GatewayState) -> JsonRpcResponse { + let active = state.active_project.read().await.clone(); + let url = match state.active_url().await { + Ok(u) => u, + Err(e) => return JsonRpcResponse::error(None, -32603, e), + }; + + let mcp_url = format!("{}/mcp", url.trim_end_matches('/')); + let rpc_body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "get_pipeline_status", + "arguments": {} + } + }); + + match state.client.post(&mcp_url).json(&rpc_body).send().await { + Ok(resp) => { + match resp.json::().await { + Ok(upstream) => { + // Extract the result from the upstream response and wrap it. + let pipeline = upstream.get("result").cloned().unwrap_or(json!(null)); + JsonRpcResponse::success( + None, + json!({ + "content": [{ + "type": "text", + "text": format!( + "Pipeline status for '{active}':\n{}", + serde_json::to_string_pretty(&pipeline).unwrap_or_default() + ) + }] + }), + ) + } + Err(e) => JsonRpcResponse::error(None, -32603, format!("invalid upstream response: {e}")), + } + } + Err(e) => JsonRpcResponse::error(None, -32603, format!("failed to reach {mcp_url}: {e}")), + } +} + +/// Aggregate health checks across all registered projects. +async fn handle_gateway_health(state: &GatewayState) -> JsonRpcResponse { + let mut results = BTreeMap::new(); + + for (name, entry) in &state.config.projects { + let health_url = format!("{}/health", entry.url.trim_end_matches('/')); + let status = match state.client.get(&health_url).send().await { + Ok(resp) => { + if resp.status().is_success() { + "healthy".to_string() + } else { + format!("unhealthy (HTTP {})", resp.status().as_u16()) + } + } + Err(e) => format!("unreachable: {e}"), + }; + results.insert(name.clone(), status); + } + + let active = state.active_project.read().await.clone(); + JsonRpcResponse::success( + None, + json!({ + "content": [{ + "type": "text", + "text": format!( + "Health check (active: '{active}'):\n{}", + results.iter() + .map(|(name, status)| format!(" {name}: {status}")) + .collect::>() + .join("\n") + ) + }] + }), + ) +} + +// ── Health aggregation endpoint ────────────────────────────────────── + +/// HTTP GET `/health` handler for the gateway — aggregates health from all projects. +#[handler] +pub async fn gateway_health_handler(state: Data<&Arc>) -> Response { + let mut all_healthy = true; + let mut statuses = BTreeMap::new(); + + for (name, entry) in &state.config.projects { + let health_url = format!("{}/health", entry.url.trim_end_matches('/')); + let healthy = match state.client.get(&health_url).send().await { + Ok(resp) => resp.status().is_success(), + Err(_) => false, + }; + if !healthy { + all_healthy = false; + } + statuses.insert(name.clone(), if healthy { "ok" } else { "error" }); + } + + let body = json!({ + "status": if all_healthy { "ok" } else { "degraded" }, + "projects": statuses, + }); + + let status = if all_healthy { StatusCode::OK } else { StatusCode::SERVICE_UNAVAILABLE }; + Response::builder() + .status(status) + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_vec(&body).unwrap_or_default())) +} + +// ── Gateway server startup ─────────────────────────────────────────── + +/// Start the gateway HTTP server. This is the entry point when `--gateway` is used. +pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> { + let config = GatewayConfig::load(config_path).map_err(std::io::Error::other)?; + let state = GatewayState::new(config).map_err(std::io::Error::other)?; + let state_arc = Arc::new(state); + + let active = state_arc.active_project.read().await.clone(); + crate::slog!("[gateway] Starting gateway on port {port}, active project: {active}"); + crate::slog!( + "[gateway] Registered projects: {}", + state_arc.config.projects.keys().cloned().collect::>().join(", ") + ); + + let route = poem::Route::new() + .at( + "/mcp", + poem::post(gateway_mcp_post_handler).get(gateway_mcp_get_handler), + ) + .at("/health", poem::get(gateway_health_handler)) + .data(state_arc); + + let host = std::env::var("HUSKIES_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); + let addr = format!("{host}:{port}"); + crate::slog!("[gateway] Listening on {addr}"); + + poem::Server::new(poem::listener::TcpListener::bind(&addr)) + .run(route) + .await +} + +// ── 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 gateway_state_rejects_empty_config() { + let config = GatewayConfig { projects: BTreeMap::new() }; + assert!(GatewayState::new(config).is_err()); + } + + #[test] + fn gateway_state_sets_first_project_active() { + let mut projects = BTreeMap::new(); + projects.insert("alpha".into(), ProjectEntry { url: "http://a:3001".into() }); + projects.insert("beta".into(), ProjectEntry { url: "http://b:3002".into() }); + let config = GatewayConfig { projects }; + let state = GatewayState::new(config).unwrap(); + let active = state.active_project.blocking_read().clone(); + assert_eq!(active, "alpha"); // BTreeMap sorts alphabetically. + } + + #[test] + fn gateway_tool_definitions_has_expected_tools() { + let defs = gateway_tool_definitions(); + let names: Vec<&str> = defs.iter() + .filter_map(|d| d.get("name").and_then(|n| n.as_str())) + .collect(); + assert!(names.contains(&"switch_project")); + assert!(names.contains(&"gateway_status")); + assert!(names.contains(&"gateway_health")); + } + + #[tokio::test] + async fn switch_project_to_known_project() { + let mut projects = BTreeMap::new(); + projects.insert("alpha".into(), ProjectEntry { url: "http://a:3001".into() }); + projects.insert("beta".into(), ProjectEntry { url: "http://b:3002".into() }); + let config = GatewayConfig { projects }; + let state = GatewayState::new(config).unwrap(); + + let params = json!({ "arguments": { "project": "beta" } }); + let resp = handle_switch_project(¶ms, &state).await; + assert!(resp.result.is_some()); + + let active = state.active_project.read().await.clone(); + assert_eq!(active, "beta"); + } + + #[tokio::test] + async fn switch_project_to_unknown_project_fails() { + let mut projects = BTreeMap::new(); + projects.insert("alpha".into(), ProjectEntry { url: "http://a:3001".into() }); + let config = GatewayConfig { projects }; + let state = GatewayState::new(config).unwrap(); + + let params = json!({ "arguments": { "project": "nonexistent" } }); + let resp = handle_switch_project(¶ms, &state).await; + assert!(resp.error.is_some()); + } + + #[tokio::test] + async fn active_url_returns_correct_url() { + let mut projects = BTreeMap::new(); + projects.insert("myproj".into(), ProjectEntry { url: "http://my:3001".into() }); + let config = GatewayConfig { projects }; + let state = GatewayState::new(config).unwrap(); + + let url = state.active_url().await.unwrap(); + assert_eq!(url, "http://my:3001"); + } + + #[test] + fn json_rpc_response_success_serializes() { + let resp = JsonRpcResponse::success(Some(json!(1)), json!({"ok": true})); + let s = serde_json::to_string(&resp).unwrap(); + assert!(s.contains("\"result\"")); + assert!(!s.contains("\"error\"")); + } + + #[test] + fn json_rpc_response_error_serializes() { + let resp = JsonRpcResponse::error(Some(json!(1)), -32600, "bad".into()); + let s = serde_json::to_string(&resp).unwrap(); + assert!(s.contains("\"error\"")); + assert!(!s.contains("\"result\"")); + } + + #[test] + fn load_config_from_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("projects.toml"); + std::fs::write(&path, r#" +[projects.test] +url = "http://localhost:9999" +"#).unwrap(); + + let config = GatewayConfig::load(&path).unwrap(); + assert_eq!(config.projects.len(), 1); + assert_eq!(config.projects["test"].url, "http://localhost:9999"); + } + + #[test] + fn load_config_missing_file_fails() { + let result = GatewayConfig::load(Path::new("/nonexistent/projects.toml")); + assert!(result.is_err()); + } +} diff --git a/server/src/main.rs b/server/src/main.rs index d1596ebe..3147ece9 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -13,6 +13,7 @@ pub mod crdt_state; pub mod crdt_sync; pub mod crdt_wire; mod db; +pub mod gateway; mod http; mod io; mod llm; @@ -53,6 +54,8 @@ struct CliArgs { agent: bool, /// Rendezvous WebSocket URL for agent mode (e.g. `ws://host:3001/crdt-sync`). rendezvous: Option, + /// Whether `--gateway` mode was requested (proxy MCP calls to per-project containers). + gateway: bool, } /// Parse CLI arguments into `CliArgs`, or exit early for `--help` / `--version`. @@ -61,6 +64,7 @@ fn parse_cli_args(args: &[String]) -> Result { let mut path: Option = None; let mut init = false; let mut agent = false; + let mut gateway = false; let mut rendezvous: Option = None; let mut i = 0; @@ -102,6 +106,9 @@ fn parse_cli_args(args: &[String]) -> Result { let val = &a["--rendezvous=".len()..]; rendezvous = Some(val.to_string()); } + "--gateway" => { + gateway = true; + } "init" => { init = true; } @@ -125,13 +132,14 @@ fn parse_cli_args(args: &[String]) -> Result { return Err("agent mode requires --rendezvous ".to_string()); } - Ok(CliArgs { port, path, init, agent, rendezvous }) + Ok(CliArgs { port, path, init, agent, rendezvous, gateway }) } fn print_help() { println!("huskies [OPTIONS] [PATH]"); println!("huskies init [OPTIONS] [PATH]"); println!("huskies agent --rendezvous [OPTIONS] [PATH]"); + println!("huskies --gateway [OPTIONS] [PATH]"); println!(); println!("Serve a huskies project."); println!(); @@ -151,6 +159,8 @@ fn print_help() { println!(" --port Port to listen on (default: 3001). Persisted to project.toml."); println!(" --rendezvous WebSocket URL of the rendezvous peer (agent mode only)."); println!(" Example: ws://server:3001/crdt-sync"); + println!(" --gateway Start in gateway mode. Reads projects.toml from PATH"); + println!(" (or cwd) and proxies MCP calls to per-project containers."); } /// Resolve the optional positional path argument into an absolute `PathBuf`. @@ -206,6 +216,7 @@ async fn main() -> Result<(), std::io::Error> { let is_init = cli.init; let is_agent = cli.agent; + let is_gateway = cli.gateway; let agent_rendezvous = cli.rendezvous.clone(); let explicit_path = resolve_path_arg(cli.path.as_deref(), &cwd); @@ -227,6 +238,17 @@ async fn main() -> Result<(), std::io::Error> { } } + // ── Gateway mode: multi-project proxy ────────────────────────────── + // + // When `huskies --gateway` is invoked, skip the normal single-project + // server and instead start a lightweight proxy that routes MCP calls + // to per-project Docker containers based on a projects.toml config. + if is_gateway { + let config_dir = explicit_path.unwrap_or_else(|| cwd.clone()); + let config_path = config_dir.join("projects.toml"); + return gateway::run(&config_path, port).await; + } + if is_init { // `huskies init [PATH]` — always scaffold, never search parents. let init_root = explicit_path.unwrap_or_else(|| cwd.clone()); @@ -949,9 +971,33 @@ name = "coder" assert_eq!(result.path, None); assert!(!result.init); assert!(!result.agent); + assert!(!result.gateway); assert_eq!(result.rendezvous, None); } + #[test] + fn parse_gateway_flag() { + let args = vec!["--gateway".to_string()]; + let result = parse_cli_args(&args).unwrap(); + assert!(result.gateway); + assert!(!result.init); + assert!(!result.agent); + } + + #[test] + fn parse_gateway_with_port_and_path() { + let args = vec![ + "--gateway".to_string(), + "--port".to_string(), + "5000".to_string(), + "/my/config".to_string(), + ]; + let result = parse_cli_args(&args).unwrap(); + assert!(result.gateway); + assert_eq!(result.port, Some(5000)); + assert_eq!(result.path, Some("/my/config".to_string())); + } + #[test] fn parse_unknown_flag_is_error() { let args = vec!["--serve".to_string()];