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>
205 lines
11 KiB
Rust
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(®istry, &mut session, "knowledge.search", &json!({ "query": "zeppelin hangar" }));
|
|
assert_eq!(paths(&before), NONE);
|
|
run(®istry, &mut session, "note.write", &json!({ "path": "Other/zep.md", "content": "# Air\n\nThe zeppelin waits in the hangar tonight.\n" }));
|
|
let after = run(®istry, &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(®istry, &mut session, "note.create", &json!({ "path": "Other/new.md", "content": "Quokka sightings near the pier.\n" }));
|
|
let created = run(®istry, &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(®istry, &mut session, "note.rename", &json!({ "from": "Trips/lisbon.md", "to": "Trips/porto.md" }));
|
|
let moved = run(®istry, &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(®istry, &mut session, "note.delete", &json!({ "path": "Trips/porto.md" }));
|
|
let gone = run(®istry, &mut session, "knowledge.search", &json!({ "query": "lisbon flight hotel" }));
|
|
assert!(paths(&gone).iter().all(|p| !p.starts_with("Trips/")), "{gone}");
|
|
run(®istry, &mut session, "folder.delete", &json!({ "path": "Other" }));
|
|
let folder = run(®istry, &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(®istry, &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(®istry, &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(®istry, &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(®istry, &mut session, "note.write", &json!({ "path": "Private/more.md", "content": "lisbon flight hunter2\n" }));
|
|
let again = run(®istry, &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(®istry, &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(®istry, &mut session, "knowledge.search", &json!({ "query": "hunter2" }))), NONE);
|
|
let status = run(®istry, &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(®istry, &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(®istry, &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(®istry, &mut session, "knowledge.search", &json!({ "query": "lisbon flight", "folder": "Trips" }));
|
|
assert_eq!(paths(&scoped), ["Trips/lisbon.md"]);
|
|
let context = run(®istry, &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(®istry, &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(®istry, &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(®istry, 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, ¶ms);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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"));
|
|
}
|