15d7f5e873
- notescraft-lint (rules, safe fixes, learned style) and notescraft-blocks (block model and edits); engine lint.* and block.* commands with CLI and MCP parity - Lint tab, underlines, slash menu with preview in Source and Visual, block grip and menu, Settings > Editor and lint, Vim block keys - Visual view: tables, note embeds, %% comments, wrapping property chips - Vault knowledge (Related tab, assistant sources, Settings) and calendar plugin - Sidebar top padding fixed, dot grid follows PaperCraft spacing, right-card tabs no longer cover the collapse button Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
165 lines
10 KiB
Rust
165 lines
10 KiB
Rust
//! Every alias: the happy path and the bad params.
|
|
|
|
mod common;
|
|
|
|
use common::{call_tool, is_error, writable_server};
|
|
use serde_json::{Value, json};
|
|
|
|
#[test]
|
|
fn the_read_only_aliases_answer() {
|
|
let (_vault, mut server) = writable_server("alias-read");
|
|
let info = call_tool(&mut server, "vault_info", &json!({}));
|
|
assert!(info.get("notes").and_then(Value::as_u64).is_some_and(|count| count >= 3), "three notes are indexed: {info}");
|
|
|
|
let listed = call_tool(&mut server, "list_notes", &json!({}));
|
|
assert!(listed.get("notes").and_then(Value::as_array).is_some_and(|notes| notes.len() >= 3), "{listed}");
|
|
let folder = call_tool(&mut server, "list_notes", &json!({ "folder": "folder" }));
|
|
assert_eq!(folder.get("notes").and_then(Value::as_array).map(Vec::len), Some(1), "{folder}");
|
|
let paged = call_tool(&mut server, "list_notes", &json!({ "limit": 1 }));
|
|
assert_eq!(paged.get("count").and_then(Value::as_u64), Some(1));
|
|
assert!(paged.get("nextCursor").and_then(Value::as_str).is_some(), "{paged}");
|
|
let next = call_tool(&mut server, "list_notes", &json!({ "limit": 1, "cursor": paged.get("nextCursor").and_then(Value::as_str).unwrap_or("0") }));
|
|
assert_ne!(next.get("notes").and_then(Value::as_array), paged.get("notes").and_then(Value::as_array), "the second page differs");
|
|
|
|
let read = call_tool(&mut server, "read_note", &json!({ "path": "Alpha.md", "include_links": true }));
|
|
assert!(read.get("content").and_then(Value::as_str).is_some_and(|text| text.contains("# Alpha")));
|
|
assert!(read.get("frontmatter").and_then(|block| block.get("tags")).is_some(), "{read}");
|
|
assert!(read.get("hash").and_then(Value::as_str).is_some_and(|hash| hash.len() == 16), "the optimistic hash comes back");
|
|
let links = read.get("links").and_then(Value::as_array).expect("links");
|
|
assert!(links.iter().any(|link| link.get("target").and_then(Value::as_str) == Some("Beta")), "{links:?}");
|
|
|
|
let search = call_tool(&mut server, "search", &json!({ "query": "Beta" }));
|
|
assert!(search.get("count").and_then(Value::as_u64).is_some_and(|count| count >= 1), "{search}");
|
|
let backlinks = call_tool(&mut server, "get_backlinks", &json!({ "path": "Beta.md" }));
|
|
assert_eq!(backlinks.get("count").and_then(Value::as_u64), Some(2), "Alpha and folder/Gamma link to Beta: {backlinks}");
|
|
let links = call_tool(&mut server, "get_links", &json!({ "path": "Alpha.md" }));
|
|
assert_eq!(links.get("links").and_then(Value::as_array).map(Vec::len), Some(1));
|
|
|
|
let tags = call_tool(&mut server, "list_tags", &json!({}));
|
|
assert_eq!(tags.get("tags").and_then(Value::as_array).map(Vec::len), Some(1), "one tag root: {tags}");
|
|
let by_tag = call_tool(&mut server, "notes_by_tag", &json!({ "tag": "work" }));
|
|
assert!(by_tag.get("notes").and_then(Value::as_array).is_some_and(|notes| notes.contains(&json!("Alpha.md"))), "{by_tag}");
|
|
|
|
let commands = call_tool(&mut server, "list_commands", &json!({}));
|
|
assert!(commands.get("commands").and_then(Value::as_array).is_some_and(|list| list.len() >= 25), "{commands}");
|
|
let run = call_tool(&mut server, "run_command", &json!({ "id": "vault.info" }));
|
|
assert!(run.get("notes").is_some(), "{run}");
|
|
}
|
|
|
|
#[test]
|
|
fn the_write_aliases_answer() {
|
|
let (vault, mut server) = writable_server("alias-write");
|
|
let root = vault.path().to_path_buf();
|
|
|
|
let created = call_tool(&mut server, "create_note", &json!({ "path": "Notes/New.md", "content": "# New\n" }));
|
|
assert_eq!(created.get("path").and_then(Value::as_str), Some("Notes/New.md"));
|
|
assert_eq!(std::fs::read_to_string(root.join("Notes/New.md")).ok().as_deref(), Some("# New\n"));
|
|
|
|
let written = call_tool(&mut server, "write_note", &json!({ "path": "Notes/New.md", "content": "# New\n\nmore\n" }));
|
|
let hash = written.get("hash").and_then(Value::as_str).unwrap_or_default().to_owned();
|
|
assert!(!hash.is_empty(), "{written}");
|
|
let guarded = call_tool(&mut server, "write_note", &json!({ "path": "Notes/New.md", "content": "x", "expected_hash": hash }));
|
|
assert!(guarded.get("hash").is_some() && guarded.get("error").is_none(), "the matching hash lets the write through: {guarded}");
|
|
let mismatch = is_error(&mut server, "write_note", &json!({ "path": "Notes/New.md", "content": "x", "expected_hash": "0000000000000000" }));
|
|
assert!(mismatch, "a stale hash is refused");
|
|
|
|
let appended = call_tool(&mut server, "append_note", &json!({ "path": "Notes/New.md", "content": "tail\n" }));
|
|
assert!(appended.get("hash").is_some(), "{appended}");
|
|
assert!(std::fs::read_to_string(root.join("Notes/New.md")).is_ok_and(|text| text.contains("tail")));
|
|
|
|
let patched = call_tool(&mut server, "patch_frontmatter", &json!({ "path": "Alpha.md", "set": { "status": "done" } }));
|
|
assert_eq!(patched.get("keys").and_then(Value::as_array).map(Vec::len), Some(1), "{patched}");
|
|
let alpha = std::fs::read_to_string(root.join("Alpha.md")).expect("read back");
|
|
assert!(alpha.contains("status: done"), "{alpha}");
|
|
assert!(alpha.contains("#work/project"), "the body is untouched: {alpha}");
|
|
let removed = call_tool(&mut server, "patch_frontmatter", &json!({ "path": "Alpha.md", "remove": ["status"] }));
|
|
assert!(!std::fs::read_to_string(root.join("Alpha.md")).is_ok_and(|text| text.contains("status: done")), "{removed}");
|
|
|
|
let renamed = call_tool(&mut server, "rename_note", &json!({ "from": "Notes/New.md", "to": "Notes/Renamed.md" }));
|
|
assert_eq!(renamed.get("to").and_then(Value::as_str), Some("Notes/Renamed.md"));
|
|
assert!(root.join("Notes/Renamed.md").exists());
|
|
|
|
let daily = call_tool(&mut server, "daily_note", &json!({ "date": "2026-02-03" }));
|
|
assert!(daily.get("path").and_then(Value::as_str).is_some_and(|path| path.contains("2026-02-03")), "{daily}");
|
|
let templated = call_tool(&mut server, "create_note", &json!({ "path": "Notes/FromTemplate.md", "template": "Alpha.md" }));
|
|
assert_eq!(templated.get("template").and_then(Value::as_str), Some("Alpha.md"), "{templated}");
|
|
assert!(std::fs::read_to_string(root.join("Notes/FromTemplate.md")).is_ok_and(|text| text.contains("# Alpha")));
|
|
}
|
|
|
|
#[test]
|
|
fn appending_under_a_heading_lands_in_that_place() {
|
|
let (vault, mut server) = writable_server("alias-heading");
|
|
let root = vault.path().to_path_buf();
|
|
common::seed(root.as_path(), &[("Headings.md", "# Top\n\nintro\n\n## Notes\nfirst\n")]);
|
|
let appended = call_tool(&mut server, "append_note", &json!({ "path": "Headings.md", "heading": "Notes", "content": "added" }));
|
|
assert!(appended.get("hash").is_some(), "{appended}");
|
|
let text = std::fs::read_to_string(root.join("Headings.md")).expect("read back");
|
|
assert_eq!(text, "# Top\n\nintro\n\n## Notes\n\nadded\n\nfirst\n", "{text}");
|
|
let missing = is_error(&mut server, "append_note", &json!({ "path": "Headings.md", "heading": "Nope", "content": "x" }));
|
|
assert!(missing, "a heading that is not there is an error, not an append at the end");
|
|
}
|
|
|
|
#[test]
|
|
fn bad_params_are_tool_errors_for_every_alias() {
|
|
let (_vault, mut server) = writable_server("alias-bad");
|
|
let cases = [
|
|
("vault_info", json!({ "nope": 1 })),
|
|
("list_notes", json!({ "limit": "many" })),
|
|
("list_notes", json!({ "cursor": "x" })),
|
|
("read_note", json!({})),
|
|
("read_note", json!({ "path": 3 })),
|
|
("read_note", json!({ "path": "Alpha.md", "include_links": "yes" })),
|
|
("create_note", json!({ "path": "x.md", "content": 1 })),
|
|
("create_note", json!({ "path": "x.md", "template": 7 })),
|
|
("write_note", json!({ "path": "Alpha.md" })),
|
|
("write_note", json!({ "path": "Alpha.md", "content": "x", "expected_hash": 9 })),
|
|
("append_note", json!({ "path": "Alpha.md" })),
|
|
("append_note", json!({ "path": "Alpha.md", "content": "x", "heading": [] })),
|
|
("patch_frontmatter", json!({ "path": "Alpha.md", "set": "nope" })),
|
|
("patch_frontmatter", json!({ "path": "Alpha.md", "set": { "a": 1 } })),
|
|
("patch_frontmatter", json!({ "path": "Alpha.md", "remove": "a" })),
|
|
("patch_frontmatter", json!({ "path": "Alpha.md", "remove": [1] })),
|
|
("rename_note", json!({ "from": "Alpha.md" })),
|
|
("delete_note", json!({})),
|
|
("save_attachment", json!({})),
|
|
("save_attachment", json!({ "ext": "png" })),
|
|
("save_attachment", json!({ "ext": "exe", "data_base64": "YWJjZA==" })),
|
|
("save_attachment", json!({ "ext": "png", "data_base64": "not*base64" })),
|
|
("save_attachment", json!({ "ext": "png", "data_base64": [] })),
|
|
("search", json!({ "query": "" })),
|
|
("search", json!({ "query": "a", "limit": 9999 })),
|
|
("read_note", json!({ "path": "../escape.md" })),
|
|
("read_note", json!({ "path": "notes.txt" })),
|
|
("search", json!({ "query": "Beta", "limit": -1 })),
|
|
("list_tags", json!({ "prefix": 4 })),
|
|
("notes_by_tag", json!({ "tag": 1 })),
|
|
("daily_note", json!({ "date": "not-a-date" })),
|
|
("run_command", json!({ "id": "note.nope" })),
|
|
("run_command", json!({ "id": "ui.palette.open" })),
|
|
("run_command", json!({ "id": "note.read", "params": "nope" })),
|
|
("run_command", json!({ "params": {} })),
|
|
("nope", json!({})),
|
|
];
|
|
for (name, arguments) in cases {
|
|
assert!(is_error(&mut server, name, &arguments), "{name} with {arguments} must be a tool error");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn every_alias_answers_with_the_mcp_payload_shape() {
|
|
let (_vault, mut server) = writable_server("alias-shape");
|
|
for alias in notescraft_mcp::aliases() {
|
|
let reply =
|
|
common::ask(&mut server, &json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": alias.name, "arguments": {}}}).to_string());
|
|
let result = reply.get("result").unwrap_or_else(|| panic!("{}: {reply}", alias.name));
|
|
let is_error = result.get("isError").and_then(Value::as_bool).expect("isError is always a boolean");
|
|
// `calendar_add` takes `text` or `event`: neither is required alone, one of them is.
|
|
let needs_args = alias.params.iter().any(|param| param.required) || alias.name == "calendar_add";
|
|
assert_eq!(is_error, needs_args, "{} with empty arguments", alias.name);
|
|
let content = result.get("content").and_then(Value::as_array).expect("content");
|
|
let first = content.first().and_then(|entry| entry.get("type")).and_then(Value::as_str);
|
|
assert_eq!(first, Some("text"), "{}", alias.name);
|
|
assert!(result.get("structuredContent").is_some(), "{} carries structured content", alias.name);
|
|
}
|
|
}
|