136 lines
5.1 KiB
Rust
136 lines
5.1 KiB
Rust
//! Fixtures: throw-away vaults and servers over them.
|
|
//!
|
|
//! Every test binary compiles this module, so a helper used by one file is
|
|
//! dead code in the others.
|
|
|
|
#![allow(dead_code)]
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
use notescraft_engine::{Registry, Session, register_core};
|
|
use notescraft_mcp::{Server, ServerOptions};
|
|
|
|
/// Counter making two vaults of the same test different.
|
|
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
|
|
/// A vault folder that deletes itself when it goes out of scope.
|
|
pub struct TempVault {
|
|
path: PathBuf,
|
|
}
|
|
|
|
impl TempVault {
|
|
/// The vault root to hand to [`Session::open`].
|
|
#[must_use]
|
|
pub fn path(&self) -> &Path {
|
|
&self.path
|
|
}
|
|
}
|
|
|
|
impl Drop for TempVault {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.path);
|
|
}
|
|
}
|
|
|
|
/// An empty vault folder named after the test and the process.
|
|
#[must_use]
|
|
pub fn temp_vault(name: &str) -> TempVault {
|
|
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
let path = std::env::temp_dir().join(format!("notescraft-mcp-{}-{name}-{unique}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&path);
|
|
std::fs::create_dir_all(&path).expect("create the temp vault");
|
|
TempVault { path }
|
|
}
|
|
|
|
/// Writes `(path, content)` pairs into `vault`, creating the folders.
|
|
pub fn seed(vault: &Path, files: &[(&str, &str)]) {
|
|
for (path, content) in files {
|
|
let target = vault.join(path);
|
|
if let Some(parent) = target.parent() {
|
|
std::fs::create_dir_all(parent).expect("create the temp folder");
|
|
}
|
|
std::fs::write(&target, content).expect("write the fixture file");
|
|
}
|
|
}
|
|
|
|
/// The fixtures every server test starts from.
|
|
#[must_use]
|
|
pub fn seeded_vault(name: &str) -> TempVault {
|
|
let vault = temp_vault(name);
|
|
seed(
|
|
vault.path(),
|
|
&[
|
|
("Alpha.md", "---\ntags: [work]\n---\n# Alpha\n\nlinks [[Beta]] and #work/project\n"),
|
|
("Beta.md", "# Beta\n\nmentions the plan\n"),
|
|
("folder/Gamma.md", "# Gamma\n\n[[Beta]]\n"),
|
|
("img.png", "not a note"),
|
|
],
|
|
);
|
|
vault
|
|
}
|
|
|
|
/// The core registry every fixture uses.
|
|
#[must_use]
|
|
pub fn registry() -> Registry<Session> {
|
|
let mut registry = Registry::new();
|
|
register_core(&mut registry).expect("register the core commands");
|
|
registry
|
|
}
|
|
|
|
/// A writable server over a fresh vault seeded with the fixtures.
|
|
pub fn writable_server(name: &str) -> (TempVault, Server) {
|
|
let vault = seeded_vault(name);
|
|
let session = Session::open(vault.path()).expect("open the vault");
|
|
(vault, Server::new(session, registry(), ServerOptions { write: true, allow_delete: false, via_control: false }))
|
|
}
|
|
|
|
/// A writable server over a vault seeded with the fixtures plus `extra` notes
|
|
/// named `extra-00000.md`, so a listing has more than one page.
|
|
pub fn server_with_extra(name: &str, extra: usize) -> (TempVault, Server) {
|
|
let vault = seeded_vault(name);
|
|
let files: Vec<(String, String)> = (0..extra).map(|index| (format!("extra-{index:05}.md"), format!("# extra {index}\n"))).collect();
|
|
let borrowed: Vec<(&str, &str)> = files.iter().map(|(path, body)| (path.as_str(), body.as_str())).collect();
|
|
seed(vault.path(), &borrowed);
|
|
let session = Session::open(vault.path()).expect("open the vault");
|
|
(vault, Server::new(session, registry(), ServerOptions { write: true, allow_delete: false, via_control: false }))
|
|
}
|
|
|
|
/// A server over a read-only session.
|
|
pub fn read_only_server(name: &str) -> (TempVault, Server) {
|
|
let vault = seeded_vault(name);
|
|
let session = Session::open_read_only(vault.path()).expect("open the vault read-only");
|
|
(vault, Server::new(session, registry(), ServerOptions { write: false, allow_delete: false, via_control: false }))
|
|
}
|
|
|
|
/// Sends one JSON-RPC line and returns the reply, failing the test when none came.
|
|
pub fn ask(server: &mut Server, line: &str) -> serde_json::Value {
|
|
let reply = server.handle_line(line).expect("a reply is due");
|
|
serde_json::from_str(&reply).unwrap_or_else(|error| panic!("the reply is JSON: {error}; {reply}"))
|
|
}
|
|
|
|
/// Calls one tool by name and returns the `structuredContent` of the result.
|
|
pub fn call_tool(server: &mut Server, name: &str, arguments: &serde_json::Value) -> serde_json::Value {
|
|
let request = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "tools/call",
|
|
"params": { "name": name, "arguments": arguments },
|
|
});
|
|
let reply = ask(server, &request.to_string());
|
|
let result = reply.get("result").unwrap_or_else(|| panic!("the call succeeded at the protocol level: {reply}"));
|
|
result.get("structuredContent").cloned().unwrap_or(serde_json::Value::Null)
|
|
}
|
|
|
|
/// Whether a tool call came back as a tool error.
|
|
pub fn is_error(server: &mut Server, name: &str, arguments: &serde_json::Value) -> bool {
|
|
let request = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "tools/call",
|
|
"params": { "name": name, "arguments": arguments },
|
|
});
|
|
let reply = ask(server, &request.to_string());
|
|
reply.get("result").and_then(|result| result.get("isError")).and_then(serde_json::Value::as_bool).unwrap_or(false)
|
|
}
|