Files
notescraft/crates/engine/tests/knowledge_commands.rs
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

205 lines
11 KiB
Rust

//! The `knowledge.*` commands: incremental upkeep, exclusions, hostile params.
mod common;
use common::{seed, temp_vault};
use notescraft_engine::{Registry, Session, SessionConfig, adversarial_params, register_core};
use serde_json::{Value, json};
const TRIP: &str = "# Trip\n\nBook the flight to Lisbon and reserve the hotel near the river.\n";
const SECRET: &str = "# Diary\n\nThe lisbon flight password is hunter2 and the river hotel is secret.\n";
fn fixture(name: &str, config: SessionConfig) -> (Registry<Session>, Session, common::TempVault) {
let vault = temp_vault(name);
seed(vault.path(), &[("Trips/lisbon.md", TRIP), ("Private/diary.md", SECRET), ("Other/cooking.md", "# Soup\n\nBoil the onions and the carrots slowly.\n")]);
let mut registry = Registry::new();
register_core(&mut registry).expect("register");
let session = Session::open_with(vault.path(), config).expect("open");
(registry, session, vault)
}
fn excluding(folder: &str) -> SessionConfig {
let mut config = SessionConfig::default();
config.knowledge.excluded = vec![folder.to_owned()];
config
}
fn run(registry: &Registry<Session>, session: &mut Session, id: &str, params: &Value) -> Value {
registry.run(id, session, params).unwrap_or_else(|error| panic!("{id}: {error:?}"))
}
const NONE: [&str; 0] = [];
fn paths(result: &Value) -> Vec<String> {
result["passages"].as_array().expect("passages").iter().map(|p| p["path"].as_str().expect("path").to_owned()).collect()
}
#[test]
fn search_finds_a_passage_after_a_write_without_rebuild() {
let (registry, mut session, _vault) = fixture("kn-write", SessionConfig::default());
let before = run(&registry, &mut session, "knowledge.search", &json!({ "query": "zeppelin hangar" }));
assert_eq!(paths(&before), NONE);
run(&registry, &mut session, "note.write", &json!({ "path": "Other/zep.md", "content": "# Air\n\nThe zeppelin waits in the hangar tonight.\n" }));
let after = run(&registry, &mut session, "knowledge.search", &json!({ "query": "zeppelin hangar" }));
assert_eq!(paths(&after), ["Other/zep.md"]);
let passage = &after["passages"][0];
assert!(passage["text"].as_str().expect("text").contains("zeppelin"));
for key in ["heading", "start", "end", "score", "signals"] {
assert!(passage.get(key).is_some(), "missing {key}");
}
assert!(after.get("truncated").is_some());
run(&registry, &mut session, "note.create", &json!({ "path": "Other/new.md", "content": "Quokka sightings near the pier.\n" }));
let created = run(&registry, &mut session, "knowledge.search", &json!({ "query": "quokka" }));
assert_eq!(paths(&created), ["Other/new.md"]);
}
#[test]
fn rename_and_delete_drop_stale_passages() {
let (registry, mut session, _vault) = fixture("kn-rename", SessionConfig::default());
run(&registry, &mut session, "note.rename", &json!({ "from": "Trips/lisbon.md", "to": "Trips/porto.md" }));
let moved = run(&registry, &mut session, "knowledge.search", &json!({ "query": "lisbon flight hotel" }));
assert_eq!(paths(&moved).iter().filter(|p| p.starts_with("Trips/")).collect::<Vec<_>>(), ["Trips/porto.md"]);
run(&registry, &mut session, "note.delete", &json!({ "path": "Trips/porto.md" }));
let gone = run(&registry, &mut session, "knowledge.search", &json!({ "query": "lisbon flight hotel" }));
assert!(paths(&gone).iter().all(|p| !p.starts_with("Trips/")), "{gone}");
run(&registry, &mut session, "folder.delete", &json!({ "path": "Other" }));
let folder = run(&registry, &mut session, "knowledge.search", &json!({ "query": "onions carrots" }));
assert_eq!(paths(&folder), NONE);
}
#[test]
fn an_external_change_is_searchable_and_a_removal_is_forgotten() {
let (registry, mut session, vault) = fixture("kn-external", SessionConfig::default());
std::fs::write(vault.path().join("Other/ext.md"), "Narwhal migration notes.\n").expect("write");
session.apply_event(notescraft_vault::VaultEvent::Created("Other/ext.md".to_owned())).expect("event");
assert_eq!(paths(&run(&registry, &mut session, "knowledge.search", &json!({ "query": "narwhal" }))), ["Other/ext.md"]);
std::fs::remove_file(vault.path().join("Other/ext.md")).expect("remove");
session.apply_event(notescraft_vault::VaultEvent::Removed("Other/ext.md".to_owned())).expect("event");
assert_eq!(paths(&run(&registry, &mut session, "knowledge.search", &json!({ "query": "narwhal" }))), NONE);
}
#[test]
fn an_excluded_folder_is_never_returned() {
let (registry, mut session, _vault) = fixture("kn-excluded", excluding("Private"));
for id in ["knowledge.search", "knowledge.context"] {
let result = run(&registry, &mut session, id, &json!({ "query": "lisbon flight river hotel password" }));
assert!(paths(&result).iter().all(|p| !p.starts_with("Private/")), "{id}: {result}");
assert!(!result.to_string().contains("hunter2"));
}
run(&registry, &mut session, "note.write", &json!({ "path": "Private/more.md", "content": "lisbon flight hunter2\n" }));
let again = run(&registry, &mut session, "knowledge.search", &json!({ "query": "hunter2" }));
assert_eq!(paths(&again), NONE);
let related = registry.run("knowledge.related", &mut session, &json!({ "path": "Private/diary.md" }));
assert!(related.is_err(), "related of an excluded note must be refused");
}
#[test]
fn changing_the_excluded_setting_drops_and_reindexes() {
let (registry, mut session, _vault) = fixture("kn-reconfig", SessionConfig::default());
assert!(paths(&run(&registry, &mut session, "knowledge.search", &json!({ "query": "hunter2" }))).contains(&"Private/diary.md".to_owned()));
let mut config = session.config().clone();
config.knowledge.excluded = vec!["Private".to_owned()];
session.set_config(config).expect("config");
assert_eq!(paths(&run(&registry, &mut session, "knowledge.search", &json!({ "query": "hunter2" }))), NONE);
let status = run(&registry, &mut session, "knowledge.status", &Value::Null);
assert_eq!(status["excluded"], json!(["Private"]));
let mut config = session.config().clone();
config.knowledge.excluded = Vec::new();
session.set_config(config).expect("config");
assert_ne!(paths(&run(&registry, &mut session, "knowledge.search", &json!({ "query": "hunter2" }))), NONE);
}
#[test]
fn related_excludes_the_note_itself() {
let (registry, mut session, _vault) = fixture("kn-related", SessionConfig::default());
let related = run(&registry, &mut session, "knowledge.related", &json!({ "path": "Trips/lisbon.md", "limit": 5 }));
let notes = related["notes"].as_array().expect("notes");
assert!(notes.iter().any(|n| n["path"] == "Private/diary.md"), "{related}");
assert!(notes.iter().all(|n| n["path"] != "Trips/lisbon.md"));
for key in ["path", "score", "snippet", "signals"] {
assert!(notes[0].get(key).is_some(), "missing {key}");
}
}
#[test]
fn folder_filter_context_budget_and_status() {
let (registry, mut session, vault) = fixture("kn-status", SessionConfig::default());
let scoped = run(&registry, &mut session, "knowledge.search", &json!({ "query": "lisbon flight", "folder": "Trips" }));
assert_eq!(paths(&scoped), ["Trips/lisbon.md"]);
let context = run(&registry, &mut session, "knowledge.context", &json!({ "query": "lisbon flight hotel", "budget": 10 }));
assert!(context["tokens"].as_u64().expect("tokens") <= 10);
assert!(context["passages"].is_array());
let status = run(&registry, &mut session, "knowledge.status", &Value::Null);
assert_eq!(status["notes"], 3);
assert!(status["passages"].as_u64().expect("passages") >= 3);
assert!(status["built_at"].is_u64());
assert!(status["last_search_ms"].is_u64());
assert!(status["vocab_terms"].is_u64());
let rebuilt = run(&registry, &mut session, "knowledge.rebuild", &Value::Null);
assert_eq!(rebuilt["notes"], 3);
assert!(vault.path().join(".notescraft/knowledge-vocab.json").is_file());
}
#[test]
fn a_cached_vocabulary_is_reused_on_open() {
let vault = temp_vault("kn-cache");
seed(vault.path(), &[("a.md", "Alpha beta gamma delta.\n\nBeta gamma epsilon.\n"), ("b.md", "Beta gamma zeta eta.\n")]);
let mut registry = Registry::new();
register_core(&mut registry).expect("register");
let terms = |session: &mut Session| run(&registry, session, "knowledge.status", &Value::Null)["vocab_terms"].clone();
let first = {
let mut session = Session::open(vault.path()).expect("open");
terms(&mut session)
};
assert!(vault.path().join(".notescraft/knowledge-vocab.json").is_file());
let mut session = Session::open(vault.path()).expect("reopen");
assert_eq!(terms(&mut session), first);
std::fs::write(vault.path().join(".notescraft/knowledge-vocab.json"), "{ nope").expect("corrupt");
drop(session);
let mut session = Session::open(vault.path()).expect("open with a bad cache");
assert_eq!(terms(&mut session), first);
}
#[test]
fn hostile_params_are_errors_not_panics() {
let (registry, mut session, _vault) = fixture("kn-hostile", SessionConfig::default());
let bad = [
json!({ "query": "x".repeat(100_000) }),
json!({ "query": "" }),
json!({ "query": "lisbon", "limit": "ten" }),
json!({ "query": "lisbon", "limit": -1 }),
json!({ "query": "lisbon", "limit": 0 }),
json!({ "query": "lisbon", "limit": 1_000_000 }),
json!({ "query": "lisbon", "limit": 1.5 }),
json!({ "query": "lisbon", "folder": 7 }),
json!({ "query": 5 }),
];
for params in &bad {
assert!(registry.run("knowledge.search", &mut session, params).is_err(), "{params}");
}
assert!(registry.run("knowledge.context", &mut session, &json!({ "query": "lisbon", "budget": 0 })).is_err());
assert!(registry.run("knowledge.context", &mut session, &json!({ "query": "lisbon", "budget": 9_999_999 })).is_err());
assert!(registry.run("knowledge.related", &mut session, &json!({ "path": "../../etc/passwd" })).is_err());
assert!(registry.run("knowledge.related", &mut session, &json!({ "path": "nope.md" })).is_ok_and(|v| v["notes"] == json!([])));
for id in ["knowledge.search", "knowledge.context", "knowledge.related", "knowledge.status", "knowledge.rebuild"] {
for params in adversarial_params() {
let _ = registry.run(id, &mut session, &params);
}
}
}
#[test]
fn the_assistant_gate_treats_knowledge_as_a_core_plugin_of_reads() {
use notescraft_engine::{READ_COMMANDS, core_plugin_of, core_plugin_off};
for id in ["knowledge.search", "knowledge.context", "knowledge.related", "knowledge.status", "knowledge.rebuild"] {
assert!(READ_COMMANDS.contains(&id));
assert_eq!(core_plugin_of(id), Some("knowledge"));
}
let (_registry, mut session, _vault) = fixture("kn-gate", SessionConfig::default());
assert_eq!(core_plugin_off(&session, "knowledge.search"), None);
let mut settings = notescraft_engine::AppSettings::default();
settings.plugins.core_disabled.insert("knowledge".to_owned());
session.apply_app_settings(&settings).expect("settings");
assert_eq!(core_plugin_off(&session, "knowledge.search"), Some("knowledge"));
}