Files
Marcos Paulo 15d7f5e873 feat: editor blocks and lint, vault knowledge, calendar plugin, macOS menu
- 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>
2026-10-18 12:36:00 -03:00

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);
}
}