Skip to main content

arcane_core/agent/
mcp.rs

1use std::sync::atomic::{AtomicBool, Ordering};
2use std::sync::mpsc;
3use std::sync::Arc;
4use std::thread::{self, JoinHandle};
5use std::time::Duration;
6
7use super::{InspectorRequest, RequestSender};
8
9/// MCP tool definition sent to clients in the tools/list response.
10#[derive(Debug)]
11struct McpTool {
12    name: &'static str,
13    description: &'static str,
14    /// JSON Schema for parameters (as a static string).
15    input_schema: &'static str,
16}
17
18/// All available MCP tools.
19static MCP_TOOLS: &[McpTool] = &[
20    McpTool {
21        name: "get_state",
22        description: "Get the full game state or a specific path within it",
23        input_schema: r#"{"type":"object","properties":{"path":{"type":"string","description":"Optional dot-separated path (e.g. 'player.hp')"}}}"#,
24    },
25    McpTool {
26        name: "describe_state",
27        description: "Get a human-readable text description of the game state",
28        input_schema: r#"{"type":"object","properties":{"verbosity":{"type":"string","enum":["minimal","normal","detailed"],"description":"Detail level"}}}"#,
29    },
30    McpTool {
31        name: "list_actions",
32        description: "List all available agent actions with descriptions and argument schemas",
33        input_schema: r#"{"type":"object","properties":{}}"#,
34    },
35    McpTool {
36        name: "execute_action",
37        description: "Execute a named agent action with optional arguments",
38        input_schema: r#"{"type":"object","properties":{"name":{"type":"string","description":"Action name"},"args":{"type":"object","description":"Optional action arguments"}},"required":["name"]}"#,
39    },
40    McpTool {
41        name: "inspect_scene",
42        description: "Query a specific value in the game state by dot-path",
43        input_schema: r#"{"type":"object","properties":{"path":{"type":"string","description":"Dot-separated state path (e.g. 'player.inventory')"}},"required":["path"]}"#,
44    },
45    McpTool {
46        name: "capture_snapshot",
47        description: "Capture a snapshot of the current game state",
48        input_schema: r#"{"type":"object","properties":{}}"#,
49    },
50    McpTool {
51        name: "hot_reload",
52        description: "Trigger a hot reload of the game entry file",
53        input_schema: r#"{"type":"object","properties":{}}"#,
54    },
55    McpTool {
56        name: "run_tests",
57        description: "Run the game's test suite and return results",
58        input_schema: r#"{"type":"object","properties":{}}"#,
59    },
60    McpTool {
61        name: "rewind",
62        description: "Reset game state to initial state (captured at registerAgent time)",
63        input_schema: r#"{"type":"object","properties":{}}"#,
64    },
65    McpTool {
66        name: "simulate_action",
67        description: "Simulate an action without committing state changes",
68        input_schema: r#"{"type":"object","properties":{"name":{"type":"string","description":"Action name"},"args":{"type":"object","description":"Optional action arguments"}},"required":["name"]}"#,
69    },
70    McpTool {
71        name: "get_frame_stats",
72        description: "Get frame timing statistics (frame time, draw calls, FPS)",
73        input_schema: r#"{"type":"object","properties":{}}"#,
74    },
75    McpTool {
76        name: "capture_frame",
77        description: "Capture the current rendered frame as a PNG image (with optional scaling to reduce file size)",
78        input_schema: r#"{"type":"object","properties":{"scale":{"type":"number","description":"Scale factor (0.1-1.0). Default 1.0. Use 0.5 to reduce file size to ~25%.","default":1.0},"regionX":{"type":"integer","description":"Region crop X coordinate (optional)"},"regionY":{"type":"integer","description":"Region crop Y coordinate (optional)"},"regionWidth":{"type":"integer","description":"Region crop width (optional)"},"regionHeight":{"type":"integer","description":"Region crop height (optional)"}}}"#,
79    },
80];
81
82/// Start the MCP server on a background thread.
83/// The MCP server uses JSON-RPC 2.0 over HTTP (Streamable HTTP transport).
84/// Returns a join handle and the actual port the server bound to (useful when port=0).
85/// The `reload_flag` allows the hot_reload tool to bypass a hung main thread
86/// by directly setting the flag instead of sending through the inspector channel.
87pub fn start_mcp_server(
88    port: u16,
89    request_tx: RequestSender,
90    reload_flag: Arc<AtomicBool>,
91) -> (JoinHandle<()>, mpsc::Receiver<u16>) {
92    let (port_tx, port_rx) = mpsc::channel();
93    let handle = thread::spawn(move || {
94        let addr = format!("0.0.0.0:{port}");
95        let server = match tiny_http::Server::http(&addr) {
96            Ok(s) => s,
97            Err(e) => {
98                eprintln!("[mcp] Failed to start on {addr}: {e}");
99                return;
100            }
101        };
102
103        // Report the actual bound port (may differ from requested when port=0)
104        let actual_port = match server.server_addr() {
105            tiny_http::ListenAddr::IP(addr) => addr.port(),
106            _ => port,
107        };
108        let _ = port_tx.send(actual_port);
109
110        eprintln!("[mcp] MCP server listening on http://localhost:{actual_port}");
111
112        for mut request in server.incoming_requests() {
113            let method = request.method().as_str().to_uppercase();
114
115            // Handle CORS preflight
116            if method == "OPTIONS" {
117                let _ = request.respond(build_cors_response());
118                continue;
119            }
120
121            if method != "POST" {
122                let resp = build_json_response(
123                    405,
124                    r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Method not allowed. Use POST."},"id":null}"#,
125                );
126                let _ = request.respond(resp);
127                continue;
128            }
129
130            // Read the request body
131            let mut body = String::new();
132            if request.as_reader().read_to_string(&mut body).is_err() {
133                let resp = build_json_response(
134                    400,
135                    r#"{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error"},"id":null}"#,
136                );
137                let _ = request.respond(resp);
138                continue;
139            }
140
141            let response_body = handle_jsonrpc(&body, &request_tx, &reload_flag);
142            let resp = build_json_response(200, &response_body);
143            let _ = request.respond(resp);
144        }
145    });
146    (handle, port_rx)
147}
148
149/// Handle a JSON-RPC 2.0 request and return the response body.
150fn handle_jsonrpc(body: &str, request_tx: &RequestSender, reload_flag: &Arc<AtomicBool>) -> String {
151    // Parse the JSON-RPC method and params
152    let rpc_method = extract_json_string(body, "method").unwrap_or_default();
153    let rpc_id = extract_json_value(body, "id").unwrap_or_else(|| "null".to_string());
154    let params = extract_json_value(body, "params").unwrap_or_else(|| "{}".to_string());
155
156    match rpc_method.as_str() {
157        "initialize" => {
158            let version = env!("CARGO_PKG_VERSION");
159            // Negotiate protocol version: use client's version if provided, else default
160            let client_version = extract_json_string(&params, "protocolVersion")
161                .unwrap_or_else(|| "2024-11-05".to_string());
162            format!(
163                r#"{{"jsonrpc":"2.0","result":{{"protocolVersion":"{client_version}","capabilities":{{"tools":{{}}}},"serverInfo":{{"name":"arcane-mcp","version":"{version}"}}}},"id":{rpc_id}}}"#,
164            )
165        }
166        "notifications/initialized" => {
167            // Client acknowledgment, no response needed for notifications
168            // But since we got it via HTTP POST, respond with empty result
169            format!(r#"{{"jsonrpc":"2.0","result":null,"id":{rpc_id}}}"#)
170        }
171        "tools/list" => {
172            let tools_json = build_tools_list();
173            format!(
174                r#"{{"jsonrpc":"2.0","result":{{"tools":{tools_json}}},"id":{rpc_id}}}"#,
175            )
176        }
177        "tools/call" => {
178            let tool_name = extract_json_string(&params, "name").unwrap_or_default();
179            let arguments =
180                extract_json_value(&params, "arguments").unwrap_or_else(|| "{}".to_string());
181
182            let result = call_tool(&tool_name, &arguments, request_tx, reload_flag);
183            let content = match result {
184                ToolResult::Text(text) => {
185                    format!(r#"{{"type":"text","text":{text}}}"#)
186                }
187                ToolResult::Image { base64, mime_type } => {
188                    format!(r#"{{"type":"image","data":"{base64}","mimeType":"{mime_type}"}}"#)
189                }
190            };
191            format!(
192                r#"{{"jsonrpc":"2.0","result":{{"content":[{content}]}},"id":{rpc_id}}}"#,
193            )
194        }
195        "ping" => {
196            format!(r#"{{"jsonrpc":"2.0","result":{{}},"id":{rpc_id}}}"#)
197        }
198        _ => {
199            format!(
200                r#"{{"jsonrpc":"2.0","error":{{"code":-32601,"message":"Method not found: {rpc_method}"}},"id":{rpc_id}}}"#,
201            )
202        }
203    }
204}
205
206/// Result of an MCP tool call — either text or an image.
207enum ToolResult {
208    Text(String),
209    Image { base64: String, mime_type: String },
210}
211
212/// Call an MCP tool by dispatching to the game loop via the inspector channel.
213fn call_tool(name: &str, arguments: &str, request_tx: &RequestSender, reload_flag: &Arc<AtomicBool>) -> ToolResult {
214    let inspector_req = match name {
215        "get_state" => {
216            let path = extract_json_string(arguments, "path");
217            InspectorRequest::GetState { path }
218        }
219        "describe_state" => {
220            let verbosity = extract_json_string(arguments, "verbosity");
221            InspectorRequest::Describe { verbosity }
222        }
223        "list_actions" => InspectorRequest::ListActions,
224        "execute_action" => {
225            let action_name = extract_json_string(arguments, "name").unwrap_or_default();
226            let args = extract_json_value(arguments, "args").unwrap_or_else(|| "{}".to_string());
227            InspectorRequest::ExecuteAction {
228                name: action_name,
229                payload: args,
230            }
231        }
232        "inspect_scene" => {
233            let path = extract_json_string(arguments, "path");
234            InspectorRequest::GetState { path }
235        }
236        "capture_snapshot" => InspectorRequest::GetHistory,
237        "hot_reload" => {
238            // Probe the game loop channel to check if the window is still running.
239            // If the receiver (mcp_rx in frame_callback) has been dropped (window closed),
240            // send() returns Err immediately — no blocking wait.
241            let (probe_tx, _probe_rx) = mpsc::channel();
242            if request_tx.send((InspectorRequest::Health, probe_tx)).is_err() {
243                return ToolResult::Text(json_encode(
244                    "{\"ok\":false,\"error\":\"Game window is not running. Start it with: arcane dev src/visual.ts\"}",
245                ));
246            }
247            // Game loop alive — set the reload flag directly.
248            // This works even if the main thread is hung (frame watchdog also detects hangs).
249            // The probe_rx is dropped here; the frame callback's send() for Health will fail
250            // silently (error is ignored with let _ = resp_tx.send(...)).
251            reload_flag.store(true, Ordering::SeqCst);
252            return ToolResult::Text(json_encode("{\"ok\":true,\"reloading\":true}"));
253        }
254        "run_tests" => {
255            // Spawn `arcane test` as a subprocess instead of going through the agent protocol.
256            // The old approach sent InspectorRequest::Simulate { action: "__run_tests__" }
257            // which required the game to register a "__run_tests__" action — no game does this.
258            let exe = std::env::current_exe().unwrap_or_else(|_| "arcane".into());
259            match std::process::Command::new(&exe)
260                .arg("test")
261                .output()
262            {
263                Ok(output) => {
264                    let stdout = String::from_utf8_lossy(&output.stdout);
265                    let stderr = String::from_utf8_lossy(&output.stderr);
266                    let combined = if stderr.is_empty() {
267                        stdout.to_string()
268                    } else {
269                        format!("{stdout}\n{stderr}")
270                    };
271                    let summary = if output.status.success() {
272                        format!("Tests passed.\n\n{combined}")
273                    } else {
274                        format!("Tests failed.\n\n{combined}")
275                    };
276                    return ToolResult::Text(json_encode(&summary));
277                }
278                Err(e) => {
279                    return ToolResult::Text(json_encode(&format!("Failed to run arcane test: {e}")));
280                }
281            }
282        }
283        "rewind" => InspectorRequest::Rewind { steps: 0 },
284        "simulate_action" => {
285            let action_name = extract_json_string(arguments, "name").unwrap_or_default();
286            let args = extract_json_value(arguments, "args").unwrap_or_else(|| "{}".to_string());
287            InspectorRequest::Simulate {
288                action: format!("{{\"name\":\"{action_name}\",\"args\":{args}}}"),
289            }
290        }
291        "get_frame_stats" => InspectorRequest::GetFrameStats,
292        "capture_frame" => {
293            let scale = extract_json_number(arguments, "scale").unwrap_or(1.0) as f32;
294            let scale = scale.clamp(0.1, 1.0);
295            let region_x = extract_json_number(arguments, "regionX").and_then(|n| Some(n as u32));
296            let region_y = extract_json_number(arguments, "regionY").and_then(|n| Some(n as u32));
297            let region_w = extract_json_number(arguments, "regionWidth").and_then(|n| Some(n as u32));
298            let region_h = extract_json_number(arguments, "regionHeight").and_then(|n| Some(n as u32));
299
300            let region = match (region_x, region_y, region_w, region_h) {
301                (Some(x), Some(y), Some(w), Some(h)) if w > 0 && h > 0 => Some((x, y, w, h)),
302                _ => None,
303            };
304
305            InspectorRequest::CaptureFrame {
306                options: crate::agent::CaptureFrameOptions { scale, region },
307            }
308        }
309        _ => {
310            return ToolResult::Text(json_encode(&format!("Unknown tool: {name}")));
311        }
312    };
313
314    // Send request to game loop and wait for response
315    let (resp_tx, resp_rx) = mpsc::channel();
316
317    if request_tx.send((inspector_req, resp_tx)).is_err() {
318        return ToolResult::Text(json_encode("Game loop disconnected"));
319    }
320
321    match resp_rx.recv_timeout(Duration::from_secs(10)) {
322        Ok(resp) => {
323            // Image responses use content_type "image/png" and body is base64-encoded PNG
324            if resp.content_type == "image/png" {
325                ToolResult::Image {
326                    base64: resp.body,
327                    mime_type: "image/png".into(),
328                }
329            } else {
330                ToolResult::Text(json_encode(&resp.body))
331            }
332        }
333        Err(_) => ToolResult::Text(json_encode("Game loop timeout")),
334    }
335}
336
337/// Build the JSON array of tool definitions.
338fn build_tools_list() -> String {
339    let tools: Vec<String> = MCP_TOOLS
340        .iter()
341        .map(|t| {
342            format!(
343                r#"{{"name":"{}","description":"{}","inputSchema":{}}}"#,
344                t.name, t.description, t.input_schema
345            )
346        })
347        .collect();
348    format!("[{}]", tools.join(","))
349}
350
351/// Encode a string as a JSON string value (with escaping).
352fn json_encode(s: &str) -> String {
353    let escaped = s
354        .replace('\\', "\\\\")
355        .replace('"', "\\\"")
356        .replace('\n', "\\n")
357        .replace('\r', "\\r")
358        .replace('\t', "\\t");
359    format!("\"{escaped}\"")
360}
361
362/// Encode bytes as base64 (standard alphabet, with padding).
363pub fn base64_encode(data: &[u8]) -> String {
364    const ALPHABET: &[u8; 64] =
365        b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
366
367    let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
368    for chunk in data.chunks(3) {
369        let b0 = chunk[0] as u32;
370        let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
371        let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
372        let triple = (b0 << 16) | (b1 << 8) | b2;
373
374        out.push(ALPHABET[((triple >> 18) & 0x3F) as usize] as char);
375        out.push(ALPHABET[((triple >> 12) & 0x3F) as usize] as char);
376        if chunk.len() > 1 {
377            out.push(ALPHABET[((triple >> 6) & 0x3F) as usize] as char);
378        } else {
379            out.push('=');
380        }
381        if chunk.len() > 2 {
382            out.push(ALPHABET[(triple & 0x3F) as usize] as char);
383        } else {
384            out.push('=');
385        }
386    }
387    out
388}
389
390/// Build an HTTP response with JSON-RPC content type.
391fn build_json_response(
392    status: u16,
393    body: &str,
394) -> tiny_http::Response<std::io::Cursor<Vec<u8>>> {
395    let data = body.as_bytes().to_vec();
396    let data_len = data.len();
397
398    let status = tiny_http::StatusCode(status);
399    let content_type =
400        tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap();
401    let cors =
402        tiny_http::Header::from_bytes(&b"Access-Control-Allow-Origin"[..], &b"*"[..]).unwrap();
403    let cors_headers = tiny_http::Header::from_bytes(
404        &b"Access-Control-Allow-Headers"[..],
405        &b"Content-Type"[..],
406    )
407    .unwrap();
408    let cors_methods = tiny_http::Header::from_bytes(
409        &b"Access-Control-Allow-Methods"[..],
410        &b"GET, POST, OPTIONS"[..],
411    )
412    .unwrap();
413
414    tiny_http::Response::new(
415        status,
416        vec![content_type, cors, cors_headers, cors_methods],
417        std::io::Cursor::new(data),
418        Some(data_len),
419        None,
420    )
421}
422
423/// Build a CORS preflight response.
424fn build_cors_response() -> tiny_http::Response<std::io::Cursor<Vec<u8>>> {
425    build_json_response(204, "")
426}
427
428// --- Simple JSON extraction (reuse inspector pattern) ---
429
430fn extract_json_string(json: &str, key: &str) -> Option<String> {
431    let pattern = format!("\"{}\"", key);
432    let start = json.find(&pattern)?;
433    let rest = &json[start + pattern.len()..];
434    let rest = rest.trim_start();
435    let rest = rest.strip_prefix(':')?;
436    let rest = rest.trim_start();
437
438    if rest.starts_with('"') {
439        let rest = &rest[1..];
440        let end = rest.find('"')?;
441        Some(rest[..end].to_string())
442    } else {
443        let end = rest
444            .find(|c: char| c == ',' || c == '}' || c == ']' || c.is_whitespace())
445            .unwrap_or(rest.len());
446        let val = rest[..end].to_string();
447        if val == "null" {
448            None
449        } else {
450            Some(val)
451        }
452    }
453}
454
455fn extract_json_number(json: &str, key: &str) -> Option<f64> {
456    let pattern = format!("\"{}\"", key);
457    let start = json.find(&pattern)?;
458    let rest = &json[start + pattern.len()..];
459    let rest = rest.trim_start();
460    let rest = rest.strip_prefix(':')?;
461    let rest = rest.trim_start();
462
463    let end = rest
464        .find(|c: char| c == ',' || c == '}' || c == ']' || c.is_whitespace())
465        .unwrap_or(rest.len());
466    rest[..end].parse::<f64>().ok()
467}
468
469fn extract_json_value(json: &str, key: &str) -> Option<String> {
470    let pattern = format!("\"{}\"", key);
471    let start = json.find(&pattern)?;
472    let rest = &json[start + pattern.len()..];
473    let rest = rest.trim_start();
474    let rest = rest.strip_prefix(':')?;
475    let rest = rest.trim_start();
476
477    if rest.starts_with('{') {
478        let mut depth = 0;
479        for (i, c) in rest.char_indices() {
480            match c {
481                '{' => depth += 1,
482                '}' => {
483                    depth -= 1;
484                    if depth == 0 {
485                        return Some(rest[..=i].to_string());
486                    }
487                }
488                _ => {}
489            }
490        }
491        None
492    } else if rest.starts_with('[') {
493        let mut depth = 0;
494        for (i, c) in rest.char_indices() {
495            match c {
496                '[' => depth += 1,
497                ']' => {
498                    depth -= 1;
499                    if depth == 0 {
500                        return Some(rest[..=i].to_string());
501                    }
502                }
503                _ => {}
504            }
505        }
506        None
507    } else if rest.starts_with('"') {
508        let inner = &rest[1..];
509        let end = inner.find('"')?;
510        Some(format!("\"{}\"", &inner[..end]))
511    } else {
512        let end = rest
513            .find(|c: char| c == ',' || c == '}' || c == ']' || c.is_whitespace())
514            .unwrap_or(rest.len());
515        Some(rest[..end].to_string())
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    #[test]
524    fn build_tools_list_is_valid_json_array() {
525        let list = build_tools_list();
526        assert!(list.starts_with('['));
527        assert!(list.ends_with(']'));
528        assert!(list.contains("get_state"));
529        assert!(list.contains("execute_action"));
530        assert!(list.contains("describe_state"));
531    }
532
533    #[test]
534    fn all_tools_have_required_fields() {
535        for tool in MCP_TOOLS {
536            assert!(!tool.name.is_empty());
537            assert!(!tool.description.is_empty());
538            assert!(tool.input_schema.starts_with('{'));
539        }
540    }
541
542    #[test]
543    fn json_encode_escapes_special_chars() {
544        assert_eq!(json_encode("hello"), r#""hello""#);
545        assert_eq!(json_encode(r#"a"b"#), r#""a\"b""#);
546        assert_eq!(json_encode("a\nb"), r#""a\nb""#);
547        assert_eq!(json_encode("a\\b"), r#""a\\b""#);
548    }
549
550    #[test]
551    fn extract_json_string_basic() {
552        let json = r#"{"name": "test", "value": 42}"#;
553        assert_eq!(extract_json_string(json, "name"), Some("test".to_string()));
554    }
555
556    #[test]
557    fn extract_json_string_null() {
558        let json = r#"{"path": null}"#;
559        assert_eq!(extract_json_string(json, "path"), None);
560    }
561
562    #[test]
563    fn extract_json_value_object() {
564        let json = r#"{"args": {"x": 1, "y": 2}}"#;
565        let val = extract_json_value(json, "args");
566        assert_eq!(val, Some(r#"{"x": 1, "y": 2}"#.to_string()));
567    }
568
569    #[test]
570    fn extract_json_value_array() {
571        let json = r#"{"items": [1, 2, 3]}"#;
572        let val = extract_json_value(json, "items");
573        assert_eq!(val, Some("[1, 2, 3]".to_string()));
574    }
575
576    fn test_reload_flag() -> Arc<AtomicBool> {
577        Arc::new(AtomicBool::new(false))
578    }
579
580    #[test]
581    fn handle_initialize() {
582        let (tx, _rx) = mpsc::channel();
583        let flag = test_reload_flag();
584        let body = r#"{"jsonrpc":"2.0","method":"initialize","id":1}"#;
585        let resp = handle_jsonrpc(body, &tx, &flag);
586        assert!(resp.contains("protocolVersion"));
587        assert!(resp.contains("arcane-mcp"));
588        assert!(resp.contains(r#""id":1"#));
589    }
590
591    #[test]
592    fn handle_tools_list() {
593        let (tx, _rx) = mpsc::channel();
594        let flag = test_reload_flag();
595        let body = r#"{"jsonrpc":"2.0","method":"tools/list","id":2}"#;
596        let resp = handle_jsonrpc(body, &tx, &flag);
597        assert!(resp.contains("get_state"));
598        assert!(resp.contains("execute_action"));
599        assert!(resp.contains(r#""id":2"#));
600    }
601
602    #[test]
603    fn handle_ping() {
604        let (tx, _rx) = mpsc::channel();
605        let flag = test_reload_flag();
606        let body = r#"{"jsonrpc":"2.0","method":"ping","id":3}"#;
607        let resp = handle_jsonrpc(body, &tx, &flag);
608        assert!(resp.contains(r#""result":{}"#));
609        assert!(resp.contains(r#""id":3"#));
610    }
611
612    #[test]
613    fn handle_unknown_method() {
614        let (tx, _rx) = mpsc::channel();
615        let flag = test_reload_flag();
616        let body = r#"{"jsonrpc":"2.0","method":"foo/bar","id":4}"#;
617        let resp = handle_jsonrpc(body, &tx, &flag);
618        assert!(resp.contains("error"));
619        assert!(resp.contains("-32601"));
620        assert!(resp.contains("foo/bar"));
621    }
622
623    #[test]
624    fn tool_count() {
625        assert_eq!(MCP_TOOLS.len(), 12);
626    }
627
628    #[test]
629    fn base64_encode_basic() {
630        assert_eq!(base64_encode(b"Hello"), "SGVsbG8=");
631        assert_eq!(base64_encode(b""), "");
632        assert_eq!(base64_encode(b"f"), "Zg==");
633        assert_eq!(base64_encode(b"fo"), "Zm8=");
634        assert_eq!(base64_encode(b"foo"), "Zm9v");
635    }
636}