//! 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, 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: &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 { 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::>(), ["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")); }