//! File I/O through MCP: every readable format opens headless, and `export` sends one //! `document.export` call whatever the backend. use std::io::{BufRead, BufReader, Write}; use serde_json::{Value, json}; use crate::{Backend, Headless, Remote, call_tool, tool_definitions}; fn tmp(name: &str) -> String { let dir = std::env::temp_dir().join(format!("vectorcraft-mcp-fileio-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); dir.join(name).to_string_lossy().to_string() } fn text(r: &crate::ToolResult) -> Value { assert!(!r.is_error, "{r:?}"); serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap() } /// A headless session with two artboards of different widths (100 and 40 pt) and a rectangle. fn two_boards() -> Headless { let mut h = Headless::new(); h.call("engine.execute", json!({"command": "file.new", "params": {"width": 100, "height": 50, "artboards": 2}})).unwrap(); h.call("engine.execute", json!({"command": "artboard.setProps", "params": {"index": 1, "width": 40}})).unwrap(); h.call("engine.execute", json!({"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 20, "height": 20}})).unwrap(); h } #[test] fn headless_opens_pdf_ai_and_templates() { let mut h = two_boards(); let pdf = h.call("engine.execute", json!({"command": "document.serialize", "params": {"format": "pdf"}})).unwrap(); let pdf = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap(); for name in ["x.pdf", "x.ai"] { let path = tmp(name); std::fs::write(&path, &pdf).unwrap(); let r = text(&call_tool(&mut h, "open_file", &json!({"path": path}))); assert_eq!(r["title"], name); assert_eq!(h.session.doc().unwrap().doc.artboards.len(), 2, "one artboard per page"); } let ait = tmp("x.ait"); std::fs::write(&ait, &pdf).unwrap(); let r = text(&call_tool(&mut h, "open_file", &json!({"path": ait}))); assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}"); let tpl = tmp("t.vectorcraft"); h.call("engine.execute", json!({"command": "file.saveAsTemplate", "params": {"path": tpl}})).unwrap(); let r = text(&call_tool(&mut h, "open_file", &json!({"path": tpl}))); assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}"); assert_eq!(h.session.doc().unwrap().path, None, "Save asks for a new name"); } #[test] fn export_tool_lists_engine_formats_and_options() { let tools = tool_definitions(); let export = tools.iter().find(|t| t["name"] == "export").unwrap(); let props = &export["inputSchema"]["properties"]; assert_eq!(props["format"]["enum"], json!(["vectorcraft", "svg", "pdf", "png", "jpg", "webp"])); for k in ["artboard", "range", "options"] { assert!(props.get(k).is_some(), "export takes {k}"); } let open = tools.iter().find(|t| t["name"] == "open_file").unwrap(); assert!(open["description"].as_str().unwrap().contains(".ait")); } /// A fake app that records `engine.execute` calls and answers each with an empty object. fn recording_app() -> (String, std::thread::JoinHandle>) { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap().to_string(); let h = std::thread::spawn(move || { let (stream, _) = listener.accept().unwrap(); let mut out = stream.try_clone().unwrap(); let mut seen = vec![]; for line in BufReader::new(stream).lines() { let msg: Value = serde_json::from_str(&line.unwrap()).unwrap(); writeln!(out, "{}", json!({"id": msg["id"], "ok": true, "result": {}})).unwrap(); seen.push(msg); } seen }); (addr, h) } #[test] fn export_is_the_same_call_in_the_app_and_headless() { let args = json!({"format": "png", "artboard": 1, "options": {"scale": 2, "artboard": 0, "range": "1-2"}}); let (addr, app) = recording_app(); let mut remote = Remote::connect(&addr).unwrap(); assert!(!call_tool(&mut remote, "export", &args).is_error); drop(remote); let seen = app.join().unwrap(); assert_eq!(seen[0]["method"], "engine.execute"); assert_eq!(seen[0]["params"], json!({"command": "document.export", "params": {"format": "png", "artboard": 1, "scale": 2}})); // Headless runs that very call: artboard 1 (40 pt wide) at scale 2. let mut h = two_boards(); let r = text(&call_tool(&mut h, "export", &args)); let png = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); assert_eq!(&png[16..20], 80u32.to_be_bytes(), "IHDR width"); let direct = h.call("engine.execute", seen[0]["params"].clone()).unwrap(); assert_eq!(direct["dataBase64"], r["dataBase64"]); } #[test] fn exporting_native_keeps_the_document_path() { let mut h = two_boards(); let doc = tmp("keep.vectorcraft"); h.call("app.save", json!({"path": doc})).unwrap(); let copy = tmp("copy.vectorcraft"); let r = text(&call_tool(&mut h, "export", &json!({"path": copy}))); assert_eq!(r["format"], "vectorcraft"); assert_eq!(h.session.doc().unwrap().path.as_deref(), Some(doc.as_str())); // PDF range through the tool: one page. let pdf = text(&call_tool(&mut h, "export", &json!({"format": "pdf", "range": "2"}))); let pdf = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap(); assert_eq!(pdf_pages(&pdf), 1); } fn pdf_pages(pdf: &[u8]) -> usize { let mut h = Headless::new(); h.call("engine.execute", json!({"command": "document.open", "params": {"name": "p.pdf", "dataBase64": vectorcraft_format::base64_encode(pdf)}})) .unwrap(); h.session.doc().unwrap().doc.artboards.len() }